From 0d80dfbfe49d9ef48d9d8b3da6431426a76d080e Mon Sep 17 00:00:00 2001 From: talge-a11y Date: Thu, 6 Aug 2026 12:43:10 +0300 Subject: [PATCH] Introduce a new CLI sandbox command to push-skills from a local dir to sandbox --- CHANGELOG.md | 2 + packages/cli/README.md | 1 + .../cli/src/cli/commands/sandbox/index.ts | 4 +- .../src/cli/commands/sandbox/push-skills.ts | 210 +++++++++++++ .../cli/src/cli/commands/sandbox/shared.ts | 32 ++ .../src/cli/commands/sandbox/write-file.ts | 9 +- packages/cli/src/core/skills/copy.ts | 55 ++++ packages/cli/src/core/skills/discover.ts | 160 ++++++++++ packages/cli/src/core/skills/index.ts | 3 + packages/cli/src/core/skills/schema.ts | 106 +++++++ packages/cli/tests/cli/sandbox.spec.ts | 78 +++++ packages/cli/tests/cli/skills_push.spec.ts | 259 ++++++++++++++++ .../cli/tests/core/skills_discover.spec.ts | 287 ++++++++++++++++++ .../fixtures/local-skills/empty/README.md | 1 + .../local-skills/multi/grill-me/SKILL.md | 6 + .../multi/grill-me/references/api.md | 3 + .../local-skills/multi/not-a-skill/README.md | 1 + .../local-skills/multi/tidy-up/SKILL.md | 6 + .../fixtures/local-skills/single/SKILL.md | 6 + .../local-skills/single/scripts/run.sh | 1 + .../local-skills/with-binary/SKILL.md | 6 + .../local-skills/with-binary/assets/logo.bin | Bin 0 -> 12 bytes 22 files changed, 1232 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/cli/commands/sandbox/push-skills.ts create mode 100644 packages/cli/src/core/skills/copy.ts create mode 100644 packages/cli/src/core/skills/discover.ts create mode 100644 packages/cli/src/core/skills/index.ts create mode 100644 packages/cli/src/core/skills/schema.ts create mode 100644 packages/cli/tests/cli/skills_push.spec.ts create mode 100644 packages/cli/tests/core/skills_discover.spec.ts create mode 100644 packages/cli/tests/fixtures/local-skills/empty/README.md create mode 100644 packages/cli/tests/fixtures/local-skills/multi/grill-me/SKILL.md create mode 100644 packages/cli/tests/fixtures/local-skills/multi/grill-me/references/api.md create mode 100644 packages/cli/tests/fixtures/local-skills/multi/not-a-skill/README.md create mode 100644 packages/cli/tests/fixtures/local-skills/multi/tidy-up/SKILL.md create mode 100644 packages/cli/tests/fixtures/local-skills/single/SKILL.md create mode 100644 packages/cli/tests/fixtures/local-skills/single/scripts/run.sh create mode 100644 packages/cli/tests/fixtures/local-skills/with-binary/SKILL.md create mode 100644 packages/cli/tests/fixtures/local-skills/with-binary/assets/logo.bin diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e18dabd..f718d8395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - `base44 build` runs the site's `buildCommand` with `VITE_BASE44_APP_ID` injected, so built bundles always carry the linked app's id. - `base44 deploy` (and `base44 site deploy`) can now build first: interactive runs ask, and `--build` / `--no-build` pre-answer the prompt. - `base44 dev --remote` serves the frontend against the production backend: it runs `site.serveCommand` with `VITE_BASE44_APP_ID` and `VITE_BASE44_APP_BASE_URL` pointing at the app's own published URL, without starting the local backend. Fails if the app has no published URL. +- `base44 sandbox push-skills ` copies local agent skills — the whole skill directory, bundled files included — into `.agents/skills/` in an app's remote sandbox. A directory holding a `SKILL.md` is one skill; otherwise its immediate subdirectories are scanned and you pick from them, with `--all` / `--name` skipping the picker. Binary files cannot cross the sandbox bridge and are reported as unsupported rather than copied. +- `base44 sandbox write --file ` reads the content from a local file, as an alternative to `--content` or piped stdin. ### Fixed diff --git a/packages/cli/README.md b/packages/cli/README.md index 4a48fb8c9..13dd0b1e1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -68,6 +68,7 @@ The CLI will guide you through project setup. For step-by-step tutorials, see th | [`sandbox grep`](https://docs.base44.com/developers/references/cli/commands/sandbox-grep) | Search files for a pattern in an app's remote sandbox | | [`sandbox run`](https://docs.base44.com/developers/references/cli/commands/sandbox-run) | Run a shell command in an app's remote sandbox | | [`sandbox checkpoint`](https://docs.base44.com/developers/references/cli/commands/sandbox-checkpoint) | Create a restore-point checkpoint of an app's remote sandbox | +| `sandbox push-skills` | Copy local agent skills into an app's remote sandbox | | [`site deploy`](https://docs.base44.com/developers/references/cli/commands/site-deploy) | Deploy built site files to Base44 hosting | | [`site open`](https://docs.base44.com/developers/references/cli/commands/site-open) | Open the published site in your browser | | [`types generate`](https://docs.base44.com/developers/references/cli/commands/types-generate) | Generate TypeScript types from project resources | diff --git a/packages/cli/src/cli/commands/sandbox/index.ts b/packages/cli/src/cli/commands/sandbox/index.ts index 3b8edb3e5..50ab583dc 100644 --- a/packages/cli/src/cli/commands/sandbox/index.ts +++ b/packages/cli/src/cli/commands/sandbox/index.ts @@ -3,6 +3,7 @@ import { getSandboxCheckpointCommand } from "./checkpoint.js"; import { getSandboxEditFileCommand } from "./edit-file.js"; import { getSandboxGrepCommand } from "./grep.js"; import { getSandboxListDirectoryCommand } from "./list-directory.js"; +import { getSandboxPushSkillsCommand } from "./push-skills.js"; import { getSandboxReadFileCommand } from "./read-file.js"; import { getSandboxRunCommandCommand } from "./run-command.js"; import { getSandboxWriteFileCommand } from "./write-file.js"; @@ -16,5 +17,6 @@ export function getSandboxCommand(): Command { .addCommand(getSandboxEditFileCommand()) .addCommand(getSandboxGrepCommand()) .addCommand(getSandboxRunCommandCommand()) - .addCommand(getSandboxCheckpointCommand()); + .addCommand(getSandboxCheckpointCommand()) + .addCommand(getSandboxPushSkillsCommand()); } diff --git a/packages/cli/src/cli/commands/sandbox/push-skills.ts b/packages/cli/src/cli/commands/sandbox/push-skills.ts new file mode 100644 index 000000000..e292e8226 --- /dev/null +++ b/packages/cli/src/cli/commands/sandbox/push-skills.ts @@ -0,0 +1,210 @@ +import type { Option as PromptOption } from "@clack/prompts"; +import { isCancel, multiselect } from "@clack/prompts"; +import type { Command } from "commander"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, onPromptCancel } from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { getAppContext } from "@/core/project/index.js"; +import type { CopySkillResult, LocalSkill } from "@/core/skills/index.js"; +import { + copySkill, + discoverLocalSkills, + SKILLS_DEST_DIR, +} from "@/core/skills/index.js"; +import { toJsonStdout } from "./shared.js"; + +const HINT_DESCRIPTION_LENGTH = 60; + +interface PushSkillsOptions { + name?: string[]; + all?: boolean; + overwrite?: boolean; +} + +function truncate(text: string, max: number): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + return collapsed.length > max + ? `${collapsed.slice(0, max - 1).trimEnd()}…` + : collapsed; +} + +/** + * Show the frontmatter name next to the directory name so a mismatch between + * the two is visible rather than silently resolved -- the directory name is + * what the skill is copied as. + */ +function buildHint(skill: LocalSkill): string | undefined { + const parts = [ + skill.name && skill.name !== skill.dirName ? skill.name : "", + skill.description + ? truncate(skill.description, HINT_DESCRIPTION_LENGTH) + : "", + ].filter(Boolean); + return parts.length > 0 ? parts.join(" — ") : undefined; +} + +function selectByName(skills: LocalSkill[], names: string[]): LocalSkill[] { + const byDirName = new Map(skills.map((skill) => [skill.dirName, skill])); + const unknown = names.filter((name) => !byDirName.has(name)); + if (unknown.length > 0) { + throw new InvalidInputError( + `No such skill: ${unknown.join(", ")}. Found: ${skills + .map((skill) => skill.dirName) + .join(", ")}.`, + ); + } + // Preserve discovery order rather than the order the flags were passed. + return skills.filter((skill) => names.includes(skill.dirName)); +} + +/** + * `null` is the "all skills" entry. Using null rather than a sentinel string + * means it can never collide with a real directory name. + */ +async function promptForSkills(skills: LocalSkill[]): Promise { + const options: PromptOption[] = [ + { value: null, label: "All skills", hint: `${skills.length} found` }, + ...skills.map((skill) => ({ + value: skill.dirName, + label: skill.dirName, + hint: buildHint(skill), + })), + ]; + + const picked = await multiselect({ + message: "Which skills do you want to copy?", + options, + required: true, + }); + if (isCancel(picked)) { + onPromptCancel(); + } + + const selected = picked as (string | null)[]; + if (selected.includes(null)) { + return skills; + } + return skills.filter((skill) => selected.includes(skill.dirName)); +} + +async function resolveSelection( + skills: LocalSkill[], + options: PushSkillsOptions, + isNonInteractive: boolean, +): Promise { + const named = options.name ?? []; + if (options.all && named.length > 0) { + throw new InvalidInputError( + "Pass either --all or --name, not both. --name would otherwise silently narrow --all to a subset.", + ); + } + if (named.length > 0) { + return selectByName(skills, named); + } + if (options.all || skills.length === 1) { + return skills; + } + // Mirrors confirmPush: never leave --json or CI waiting on a prompt. + if (isNonInteractive) { + throw new InvalidInputError( + `Found ${skills.length} skills. Pass --all or --name to choose in non-interactive mode.`, + ); + } + return await promptForSkills(skills); +} + +function reportBinarySkips( + log: CLIContext["log"], + results: CopySkillResult[], +): void { + const skipped = results.flatMap((result) => + result.skippedBinary.map((path) => `${result.skill}/${path}`), + ); + if (skipped.length === 0) { + return; + } + log.warn( + [ + "Binary files are not supported by the sandbox bridge and were not copied:", + ...skipped.map((path) => ` ${path}`), + " The rest of the skill was copied. Remove them or replace them with text", + " equivalents if the skill depends on them.", + ].join("\n"), + ); +} + +async function pushSkillsAction( + { isNonInteractive, jsonMode, log, runTask }: CLIContext, + dir: string, + options: PushSkillsOptions, +): Promise { + const { id: appId } = getAppContext(); + + const skills = await discoverLocalSkills(dir); + const selected = await resolveSelection( + skills, + options, + isNonInteractive || jsonMode, + ); + + const results: CopySkillResult[] = []; + for (const skill of selected) { + const result = await runTask( + `Copying ${skill.dirName}`, + () => copySkill(appId, skill, { overwrite: options.overwrite }), + { + successMessage: `Copied ${skill.dirName}`, + errorMessage: `Failed to copy ${skill.dirName}`, + }, + ); + results.push(result); + } + + const fileCount = results.reduce( + (total, result) => total + result.written.length, + 0, + ); + log.success( + `Copied ${results.length} skill(s): ${results + .map((result) => result.skill) + .join(", ")}`, + ); + reportBinarySkips(log, results); + + return { + outroMessage: `Copied ${fileCount} file(s) to ${SKILLS_DEST_DIR}`, + // Automation needs to know exactly what landed and what was dropped; the + // binary warning above only reaches stderr. + ...(jsonMode + ? { + stdout: toJsonStdout({ + destination: SKILLS_DEST_DIR, + skills: results, + }), + } + : {}), + }; +} + +export function getSandboxPushSkillsCommand(): Command { + return new Base44Command("push-skills") + .description( + `Copy local agent skills into an app's remote sandbox (${SKILLS_DEST_DIR})`, + ) + .argument("", "Local skill directory, or a directory of skills") + .option( + "--name ", + "Copy only these skills, by directory name (skips the picker)", + ) + .option("--all", "Copy every discovered skill (skips the picker)") + .option("--overwrite", "Overwrite files that already exist in the sandbox") + .addHelpText( + "after", + ` +Examples: + $ base44 sandbox push-skills ~/.claude/skills --app-id app_123 + $ base44 sandbox push-skills ./.claude/skills/deploy-check --overwrite + $ base44 sandbox push-skills ~/.claude/skills --name grill-me --name tidy-up`, + ) + .action(pushSkillsAction); +} diff --git a/packages/cli/src/cli/commands/sandbox/shared.ts b/packages/cli/src/cli/commands/sandbox/shared.ts index baaaf2dd3..c44d9a261 100644 --- a/packages/cli/src/cli/commands/sandbox/shared.ts +++ b/packages/cli/src/cli/commands/sandbox/shared.ts @@ -1,5 +1,7 @@ +import { resolve } from "node:path"; import { readStdin } from "@/cli/utils/index.js"; import { InvalidInputError } from "@/core/errors.js"; +import { pathExists, readTextFile } from "@/core/utils/fs.js"; // Re-exported from the shared util so both sandbox and workspace commands use // one implementation of the `--json` serializer. @@ -25,6 +27,36 @@ export async function resolveFlagOrStdin( return readStdin(flagName, { trim: false }); } +/** + * Resolve file content from `--content`, `--file `, or piped stdin, in + * that order. Exactly one source may be given; passing both flags is an error + * rather than a silent precedence win. + */ +export async function resolveContentSource( + content: string | undefined, + file: string | undefined, +): Promise { + if (content !== undefined && file !== undefined) { + throw new InvalidInputError("Pass either --content or --file, not both."); + } + if (content !== undefined) { + return content; + } + if (file !== undefined) { + const filePath = resolve(file); + if (!(await pathExists(filePath))) { + throw new InvalidInputError(`File not found: ${filePath}`); + } + return await readTextFile(filePath); + } + if (process.stdin.isTTY) { + throw new InvalidInputError( + "Provide --content, --file , or pipe the value via stdin (e.g. echo | base44 sandbox write ...).", + ); + } + return readStdin("--content", { trim: false }); +} + /** * Parse a CLI option string as a positive integer, or return undefined when * the option was not provided. Throws InvalidInputError on a malformed value. diff --git a/packages/cli/src/cli/commands/sandbox/write-file.ts b/packages/cli/src/cli/commands/sandbox/write-file.ts index 2a8b3127e..e80120bf2 100644 --- a/packages/cli/src/cli/commands/sandbox/write-file.ts +++ b/packages/cli/src/cli/commands/sandbox/write-file.ts @@ -3,10 +3,11 @@ import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { Base44Command } from "@/cli/utils/index.js"; import { getAppContext } from "@/core/project/index.js"; import { writeFile } from "@/core/resources/sandbox/api.js"; -import { resolveFlagOrStdin, toJsonStdout } from "./shared.js"; +import { resolveContentSource, toJsonStdout } from "./shared.js"; interface WriteFileOptions { content?: string; + file?: string; overwrite?: boolean; } @@ -16,7 +17,7 @@ async function writeFileAction( options: WriteFileOptions, ): Promise { const { id: appId } = getAppContext(); - const content = await resolveFlagOrStdin(options.content, "--content"); + const content = await resolveContentSource(options.content, options.file); const result = await runTask("Writing file", () => writeFile(appId, { path, content, overwrite: options.overwrite }), @@ -30,13 +31,15 @@ export function getSandboxWriteFileCommand(): Command { .description("Create or overwrite a file in an app's remote sandbox") .argument("", "File path relative to the app root") .option("--content ", "File content (if omitted, read from stdin)") + .option("--file ", "Read the content from a local file") .option("--overwrite", "Overwrite the file if it already exists") .addHelpText( "after", ` Examples: $ echo "hello" | base44 sandbox write notes.txt - $ base44 sandbox write notes.txt --content "hello" --overwrite`, + $ base44 sandbox write notes.txt --content "hello" --overwrite + $ base44 sandbox write notes.txt --file ./local-notes.txt`, ) .action(writeFileAction); } diff --git a/packages/cli/src/core/skills/copy.ts b/packages/cli/src/core/skills/copy.ts new file mode 100644 index 000000000..a3e3ed035 --- /dev/null +++ b/packages/cli/src/core/skills/copy.ts @@ -0,0 +1,55 @@ +import { writeFile as writeSandboxFile } from "@/core/resources/sandbox/api.js"; +import { readFile } from "@/core/utils/fs.js"; +import type { LocalSkill } from "./schema.js"; +import { isBinary, SKILLS_DEST_DIR } from "./schema.js"; + +export interface CopySkillResult { + /** The skill's directory name, which is also its destination folder. */ + skill: string; + /** Remote paths written, relative to the app root. */ + written: string[]; + /** Paths (relative to the skill directory) skipped because they are binary. */ + skippedBinary: string[]; +} + +/** + * Destination path for a file inside a skill. Remote paths are always POSIX, + * so this joins with "/" rather than path.join. + */ +function remoteSkillPath(dirName: string, relativePath: string): string { + return `${SKILLS_DEST_DIR}/${dirName}/${relativePath}`; +} + +/** + * Copy one skill's directory into SKILLS_DEST_DIR in the app's sandbox. + * Binary files are reported back rather than written — see isBinary(). + */ +export async function copySkill( + appId: string, + skill: LocalSkill, + options: { overwrite?: boolean } = {}, +): Promise { + const written: string[] = []; + const skippedBinary: string[] = []; + + // Sequential on purpose: the bridge takes one file per request, and a skill + // with a large references/ directory would otherwise fire dozens of + // concurrent writes at it. + for (const file of skill.files) { + const buffer = await readFile(file.absolutePath); + if (isBinary(buffer)) { + skippedBinary.push(file.relativePath); + continue; + } + + const path = remoteSkillPath(skill.dirName, file.relativePath); + await writeSandboxFile(appId, { + path, + content: buffer.toString("utf-8"), + overwrite: options.overwrite, + }); + written.push(path); + } + + return { skill: skill.dirName, written, skippedBinary }; +} diff --git a/packages/cli/src/core/skills/discover.ts b/packages/cli/src/core/skills/discover.ts new file mode 100644 index 000000000..01820d55f --- /dev/null +++ b/packages/cli/src/core/skills/discover.ts @@ -0,0 +1,160 @@ +import { lstat, readdir, realpath, stat } from "node:fs/promises"; +import { basename, join, resolve } from "node:path"; +import frontmatter from "front-matter"; +import { globby } from "globby"; +import { InvalidInputError } from "@/core/errors.js"; +import { pathExists, readTextFile } from "@/core/utils/fs.js"; +import type { LocalSkill, SkillFile } from "./schema.js"; +import { + assertSafeDirName, + assertSafeRelativePath, + assertWithinSkillRoot, + IGNORED_PATTERNS, + SKILL_FILE, +} from "./schema.js"; + +interface SkillFrontmatter { + name?: unknown; + description?: unknown; +} + +/** + * Pull `name` and `description` out of a SKILL.md. Both are display-only — + * neither is required, and neither affects where the skill is copied. + */ +async function readSkillMetadata( + skillDir: string, +): Promise<{ name: string; description: string }> { + const raw = await readTextFile(join(skillDir, SKILL_FILE)); + const { attributes } = frontmatter(raw); + return { + name: typeof attributes.name === "string" ? attributes.name.trim() : "", + description: + typeof attributes.description === "string" + ? attributes.description.trim() + : "", + }; +} + +async function readSkill(skillDir: string): Promise { + const dirName = basename(skillDir); + assertSafeDirName(dirName); + + const { name, description } = await readSkillMetadata(skillDir); + + // globby yields forward slashes on every platform, which is what the remote + // paths need — do not normalize these to the OS separator. + // + // followSymbolicLinks is off so a link pointing at a parent directory cannot + // pull unrelated local files into the upload. The relative path alone cannot + // catch that: it still looks like it sits inside the skill. + const relativePaths = await globby("**/*", { + cwd: skillDir, + onlyFiles: true, + followSymbolicLinks: false, + ignore: IGNORED_PATTERNS, + }); + + // Resolve the root once so links are compared against the real directory, + // not the path we were handed (which may itself be a link). + const realRoot = await realpath(skillDir); + + const files: SkillFile[] = await Promise.all( + relativePaths.sort().map(async (relativePath) => { + assertSafeRelativePath(relativePath, dirName); + const absolutePath = join(skillDir, relativePath); + assertWithinSkillRoot(realRoot, await realpath(absolutePath), dirName); + return { relativePath, absolutePath }; + }), + ); + + return { dirName, name, description, absolutePath: skillDir, files }; +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +/** + * Does this directory carry a usable skill marker? + * + * lstat, not stat: a symlinked SKILL.md reads fine but is excluded from the + * copy by followSymbolicLinks, which would ship a skill with no SKILL.md and + * report success. Refuse it outright rather than let that through. + */ +async function hasRegularSkillFile(dir: string): Promise { + const markerPath = join(dir, SKILL_FILE); + try { + const stats = await lstat(markerPath); + if (stats.isFile()) { + return true; + } + throw new InvalidInputError( + `${markerPath} must be a regular file (found a ${stats.isSymbolicLink() ? "symlink" : "directory"}). A ${SKILL_FILE} that is not a regular file is never copied, which would leave the skill unusable.`, + ); + } catch (error) { + if (error instanceof InvalidInputError) { + throw error; + } + return false; + } +} + +async function findSkillSubdirectories(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const candidates = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => join(dir, entry.name)) + .sort(); + + const skillDirs: string[] = []; + for (const candidate of candidates) { + if (await hasRegularSkillFile(candidate)) { + skillDirs.push(candidate); + } + } + return skillDirs; +} + +/** + * Resolve a local directory into the skills it contains. + * + * - `/SKILL.md` exists -> the directory is itself one skill. + * - otherwise -> every immediate subdirectory holding a SKILL.md is a skill. + * + * Only one level of nesting is scanned; that matches how skills are laid out + * on disk and keeps the picker free of unrelated directories. + * + * @throws InvalidInputError when the path is missing, is not a directory, + * holds no SKILL.md at either level, or holds a SKILL.md that is not a + * regular file. + */ +export async function discoverLocalSkills(dir: string): Promise { + const skillsDir = resolve(dir); + + if (!(await pathExists(skillsDir))) { + throw new InvalidInputError(`Directory not found: ${skillsDir}`); + } + if (!(await isDirectory(skillsDir))) { + throw new InvalidInputError( + `Not a directory: ${skillsDir}. Point this command at a skill directory, or at a directory containing skill directories.`, + ); + } + + if (await hasRegularSkillFile(skillsDir)) { + return [await readSkill(skillsDir)]; + } + + const skillDirs = await findSkillSubdirectories(skillsDir); + if (skillDirs.length === 0) { + throw new InvalidInputError( + `No skills found in ${skillsDir}. Expected a ${SKILL_FILE} in that directory, or in one of its immediate subdirectories.`, + ); + } + + return await Promise.all(skillDirs.map(readSkill)); +} diff --git a/packages/cli/src/core/skills/index.ts b/packages/cli/src/core/skills/index.ts new file mode 100644 index 000000000..55eceed18 --- /dev/null +++ b/packages/cli/src/core/skills/index.ts @@ -0,0 +1,3 @@ +export * from "./copy.js"; +export * from "./discover.js"; +export * from "./schema.js"; diff --git a/packages/cli/src/core/skills/schema.ts b/packages/cli/src/core/skills/schema.ts new file mode 100644 index 000000000..b67d7331a --- /dev/null +++ b/packages/cli/src/core/skills/schema.ts @@ -0,0 +1,106 @@ +import { isAbsolute, relative } from "node:path"; +import { InvalidInputError } from "@/core/errors.js"; + +/** Filename that marks a directory as a skill. */ +export const SKILL_FILE = "SKILL.md"; + +/** + * Where skills land inside the app repo. Deliberately not configurable — the + * in-sandbox agent looks here, so a per-invocation override would only ever + * produce skills it cannot find. + */ +export const SKILLS_DEST_DIR = ".agents/skills"; + +/** Glob patterns never copied, regardless of where they appear in a skill. */ +export const IGNORED_PATTERNS = [ + "**/node_modules/**", + "**/.git/**", + "**/.DS_Store", +]; + +export interface SkillFile { + /** Path relative to the skill directory. Always POSIX-separated. */ + relativePath: string; + absolutePath: string; +} + +export interface LocalSkill { + /** + * Directory basename. This is the destination folder name — a skill's + * directory name is what identifies it on disk. + */ + dirName: string; + /** `name` from SKILL.md frontmatter. Empty when absent; display only. */ + name: string; + /** `description` from SKILL.md frontmatter. Empty when absent; display only. */ + description: string; + absolutePath: string; + files: SkillFile[]; +} + +/** + * Guard a skill directory name before it becomes a remote path segment. + * A name carrying a separator or a `..` would let a skill write outside + * SKILLS_DEST_DIR. + */ +export function assertSafeDirName(dirName: string): void { + if ( + dirName === "" || + dirName === "." || + dirName === ".." || + dirName.includes("/") || + dirName.includes("\\") + ) { + throw new InvalidInputError( + `Invalid skill directory name: "${dirName}". Skill directory names cannot be empty, "." or "..", or contain path separators.`, + ); + } +} + +/** + * Guard a collected file path before it becomes a remote path segment. globby + * yields paths relative to the skill root, but a symlink or an unusual entry + * could still produce something that escapes it. + */ +export function assertSafeRelativePath( + relativePath: string, + dirName: string, +): void { + if ( + relativePath === "" || + isAbsolute(relativePath) || + relativePath.split("/").includes("..") + ) { + throw new InvalidInputError( + `Skill "${dirName}" contains a file path that escapes its directory: "${relativePath}".`, + ); + } +} + +/** + * Guard against a symlink that points outside the skill. The apparent path + * stays inside the skill directory, so assertSafeRelativePath cannot catch + * this — only the resolved real path can. Both arguments must already be + * realpath-resolved. + */ +export function assertWithinSkillRoot( + realRoot: string, + realPath: string, + dirName: string, +): void { + const rel = relative(realRoot, realPath); + if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { + throw new InvalidInputError( + `Skill "${dirName}" contains a link that resolves outside its directory: "${realPath}". Remove it and try again.`, + ); + } +} + +/** + * The sandbox bridge's write_file takes `content` as a plain string with no + * encoding field, so a binary payload cannot survive the round trip. Detect it + * up front with a NUL-byte scan and skip rather than write corrupted bytes. + */ +export function isBinary(buffer: Buffer): boolean { + return buffer.includes(0); +} diff --git a/packages/cli/tests/cli/sandbox.spec.ts b/packages/cli/tests/cli/sandbox.spec.ts index 18c20dfae..5fdf8e060 100644 --- a/packages/cli/tests/cli/sandbox.spec.ts +++ b/packages/cli/tests/cli/sandbox.spec.ts @@ -1,3 +1,5 @@ +import { writeFile as writeLocalFile } from "node:fs/promises"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { setupCLITests } from "./testkit/index.js"; @@ -133,6 +135,82 @@ describe("sandbox commands", () => { t.expectResult(result).toContain('"bytesWritten": 6'); }); + it("write reads content from a local file with --file", async () => { + // Given + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + const localPath = join(t.getTempDir(), "local-notes.txt"); + await writeLocalFile(localPath, "from a file"); + t.api.mockRoute("POST", `${base}/write_file`, (req, res) => { + res.status(200).json({ + path: req.body.path, + bytes_written: (req.body.content ?? "").length, + created: true, + overwritten: false, + }); + }); + + // When + const result = await t.run( + "sandbox", + "write", + "notes.txt", + "--file", + localPath, + "--app-id", + APP_ID, + ); + + // Then — 11 bytes ("from a file"), read from disk rather than stdin + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain('"bytesWritten": 11'); + }); + + it("write rejects --content and --file together", async () => { + // Given + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + const localPath = join(t.getTempDir(), "local-notes.txt"); + await writeLocalFile(localPath, "from a file"); + + // When + const result = await t.run( + "sandbox", + "write", + "notes.txt", + "--content", + "inline", + "--file", + localPath, + "--app-id", + APP_ID, + ); + + // Then + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "Pass either --content or --file, not both.", + ); + }); + + it("write reports a missing --file path", async () => { + // Given + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + // When + const result = await t.run( + "sandbox", + "write", + "notes.txt", + "--file", + join(t.getTempDir(), "nope.txt"), + "--app-id", + APP_ID, + ); + + // Then + t.expectResult(result).toFail(); + t.expectResult(result).toContain("File not found"); + }); + it("run surfaces the remote exit code without failing the CLI", async () => { // Given await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); diff --git a/packages/cli/tests/cli/skills_push.spec.ts b/packages/cli/tests/cli/skills_push.spec.ts new file mode 100644 index 000000000..d1488a999 --- /dev/null +++ b/packages/cli/tests/cli/skills_push.spec.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +const APP_ID = "test-app-id"; +const base = `/api/apps/${APP_ID}/sandbox-bridge`; + +interface WriteCall { + path: string; + content: string; + overwrite?: boolean; +} + +describe("sandbox push-skills", () => { + const t = setupCLITests(); + + /** Mock write_file and record every call the CLI makes. */ + function captureWrites(): WriteCall[] { + const calls: WriteCall[] = []; + t.api.mockRoute("POST", `${base}/write_file`, (req, res) => { + calls.push({ + path: req.body.path, + content: req.body.content, + overwrite: req.body.overwrite, + }); + res.status(200).json({ + path: req.body.path, + bytes_written: (req.body.content ?? "").length, + created: true, + overwritten: false, + }); + }); + return calls; + } + + it("copies a single-skill directory without asking which to copy", async () => { + // Given — the directory itself holds SKILL.md, so there is nothing to pick + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + const calls = captureWrites(); + + // When + const result = await t.run( + "sandbox", + "push-skills", + fixture("local-skills/single"), + "--app-id", + APP_ID, + ); + + // Then + t.expectResult(result).toSucceed(); + expect(calls.map((call) => call.path).sort()).toEqual([ + ".agents/skills/single/SKILL.md", + ".agents/skills/single/scripts/run.sh", + ]); + }); + + it("--all copies every discovered skill under .agents/skills", async () => { + // Given + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + const calls = captureWrites(); + + // When + const result = await t.run( + "sandbox", + "push-skills", + fixture("local-skills/multi"), + "--all", + "--app-id", + APP_ID, + ); + + // Then — bundled files travel too, and non-skill directories are ignored + t.expectResult(result).toSucceed(); + expect(calls.map((call) => call.path).sort()).toEqual([ + ".agents/skills/grill-me/SKILL.md", + ".agents/skills/grill-me/references/api.md", + ".agents/skills/tidy-up/SKILL.md", + ]); + t.expectResult(result).toNotContain("not-a-skill"); + }); + + it("--name copies only the named skills", async () => { + // Given + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + const calls = captureWrites(); + + // When + const result = await t.run( + "sandbox", + "push-skills", + fixture("local-skills/multi"), + "--name", + "tidy-up", + "--app-id", + APP_ID, + ); + + // Then + t.expectResult(result).toSucceed(); + expect(calls.map((call) => call.path)).toEqual([ + ".agents/skills/tidy-up/SKILL.md", + ]); + }); + + it("fails when --name does not match a discovered skill", async () => { + // Given + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + captureWrites(); + + // When + const result = await t.run( + "sandbox", + "push-skills", + fixture("local-skills/multi"), + "--name", + "nope", + "--app-id", + APP_ID, + ); + + // Then — the error names what was actually found + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No such skill: nope"); + t.expectResult(result).toContain("grill-me"); + }); + + it("requires --all or --name in non-interactive mode", async () => { + // Given — more than one skill, so a choice is needed and no prompt is possible + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + captureWrites(); + + // When + const result = await t.run( + "sandbox", + "push-skills", + fixture("local-skills/multi"), + "--app-id", + APP_ID, + ); + + // Then + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "Pass --all or --name to choose in non-interactive mode", + ); + }); + + it("--overwrite is forwarded to the sandbox bridge", async () => { + // Given + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + const calls = captureWrites(); + + // When + const result = await t.run( + "sandbox", + "push-skills", + fixture("local-skills/single"), + "--overwrite", + "--app-id", + APP_ID, + ); + + // Then + t.expectResult(result).toSucceed(); + expect(calls.every((call) => call.overwrite === true)).toBe(true); + }); + + it("never writes binary files, and explains that they are unsupported", async () => { + // Given — the skill bundles a file containing NUL bytes + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + const calls = captureWrites(); + + // When + const result = await t.run( + "sandbox", + "push-skills", + fixture("local-skills/with-binary"), + "--app-id", + APP_ID, + ); + + // Then — the text file still ships, the binary one does not + t.expectResult(result).toSucceed(); + expect(calls.map((call) => call.path)).toEqual([ + ".agents/skills/with-binary/SKILL.md", + ]); + t.expectResult(result).toContain("Binary files are not supported"); + t.expectResult(result).toContain("with-binary/assets/logo.bin"); + }); + + it("rejects --all and --name together", async () => { + // Given + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + captureWrites(); + + // When + const result = await t.run( + "sandbox", + "push-skills", + fixture("local-skills/multi"), + "--all", + "--name", + "tidy-up", + "--app-id", + APP_ID, + ); + + // Then — --name would otherwise silently narrow --all + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Pass either --all or --name, not both"); + }); + + it("--json reports what was written and what was skipped", async () => { + // Given — the skill bundles a binary file that cannot be copied + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + captureWrites(); + + // When + const result = await t.run( + "sandbox", + "push-skills", + fixture("local-skills/with-binary"), + "--app-id", + APP_ID, + "--json", + ); + + // Then — stdout parses cleanly and carries the full outcome + t.expectResult(result).toSucceed(); + const parsed = JSON.parse(result.stdout); + expect(parsed.destination).toBe(".agents/skills"); + expect(parsed.skills).toEqual([ + { + skill: "with-binary", + written: [".agents/skills/with-binary/SKILL.md"], + skippedBinary: ["assets/logo.bin"], + }, + ]); + expect(result.stdout).not.toContain("Copied"); + }); + + it("explains when the directory holds no skills", async () => { + // Given + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + + // When + const result = await t.run( + "sandbox", + "push-skills", + fixture("local-skills/empty"), + "--app-id", + APP_ID, + ); + + // Then + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No skills found"); + }); +}); diff --git a/packages/cli/tests/core/skills_discover.spec.ts b/packages/cli/tests/core/skills_discover.spec.ts new file mode 100644 index 000000000..4763cbaca --- /dev/null +++ b/packages/cli/tests/core/skills_discover.spec.ts @@ -0,0 +1,287 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { InvalidInputError } from "../../src/core/errors.js"; +import { discoverLocalSkills } from "../../src/core/skills/discover.js"; +import { + assertSafeDirName, + assertSafeRelativePath, + assertWithinSkillRoot, + isBinary, +} from "../../src/core/skills/schema.js"; + +const SKILL_MD = + "---\nname: grill-me\ndescription: Ask hard questions.\n---\n\nBody.\n"; + +describe("discoverLocalSkills", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "local-skills-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + async function writeSkill(skillDir: string, contents = SKILL_MD) { + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, "SKILL.md"), contents); + } + + it("treats a directory holding SKILL.md as a single skill", async () => { + // Given + await writeSkill(dir); + await mkdir(join(dir, "references"), { recursive: true }); + await writeFile(join(dir, "references", "api.md"), "# Reference\n"); + + // When + const skills = await discoverLocalSkills(dir); + + // Then + expect(skills).toHaveLength(1); + expect(skills[0]?.name).toBe("grill-me"); + expect(skills[0]?.description).toBe("Ask hard questions."); + expect(skills[0]?.files.map((file) => file.relativePath)).toEqual([ + "SKILL.md", + "references/api.md", + ]); + }); + + it("scans one level of subdirectories when there is no root SKILL.md", async () => { + // Given — two skills plus a directory that is not one + await writeSkill(join(dir, "grill-me")); + await writeSkill(join(dir, "tidy-up")); + await mkdir(join(dir, "not-a-skill"), { recursive: true }); + await writeFile(join(dir, "not-a-skill", "README.md"), "nope\n"); + + // When + const skills = await discoverLocalSkills(dir); + + // Then — only the qualifying directories, in sorted order + expect(skills.map((skill) => skill.dirName)).toEqual([ + "grill-me", + "tidy-up", + ]); + }); + + it("does not descend past the first level of subdirectories", async () => { + // Given — the SKILL.md is two levels down + await writeSkill(join(dir, "nested", "grill-me")); + + // When / Then + await expect(discoverLocalSkills(dir)).rejects.toThrow(InvalidInputError); + }); + + it("uses the directory name as the skill identity, not the frontmatter name", async () => { + // Given — frontmatter name deliberately disagrees with the directory + await writeSkill(join(dir, "on-disk-name")); + + // When + const skills = await discoverLocalSkills(dir); + + // Then + expect(skills[0]?.dirName).toBe("on-disk-name"); + expect(skills[0]?.name).toBe("grill-me"); + }); + + it("tolerates a SKILL.md with no frontmatter", async () => { + // Given + await writeSkill(join(dir, "bare"), "Just a body, no frontmatter.\n"); + + // When + const skills = await discoverLocalSkills(dir); + + // Then + expect(skills[0]).toMatchObject({ + dirName: "bare", + name: "", + description: "", + }); + }); + + it("ignores node_modules and dotfiles inside a skill", async () => { + // Given + await writeSkill(dir); + await mkdir(join(dir, "node_modules", "pkg"), { recursive: true }); + await writeFile(join(dir, "node_modules", "pkg", "index.js"), "module\n"); + await writeFile(join(dir, ".DS_Store"), "junk\n"); + + // When + const skills = await discoverLocalSkills(dir); + + // Then + expect(skills[0]?.files.map((file) => file.relativePath)).toEqual([ + "SKILL.md", + ]); + }); + + it("does not follow a symlinked directory out of the skill", async () => { + // Given — a link inside the skill pointing at an unrelated local directory + await writeSkill(dir); + const outside = await mkdtemp(join(tmpdir(), "outside-")); + try { + await writeFile(join(outside, "secret.txt"), "not yours\n"); + await symlink(outside, join(dir, "escape"), "dir"); + + // When + const skills = await discoverLocalSkills(dir); + + // Then — the linked file is never collected + expect(skills[0]?.files.map((file) => file.relativePath)).toEqual([ + "SKILL.md", + ]); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + + it("does not follow a symlinked file out of the skill", async () => { + // Given + await writeSkill(dir); + const outside = await mkdtemp(join(tmpdir(), "outside-")); + try { + const secret = join(outside, "secret.txt"); + await writeFile(secret, "not yours\n"); + await symlink(secret, join(dir, "secret.txt")); + + // When + const skills = await discoverLocalSkills(dir); + + // Then + expect(skills[0]?.files.map((file) => file.relativePath)).toEqual([ + "SKILL.md", + ]); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + + it("rejects a symlinked SKILL.md at the root rather than shipping a skill without it", async () => { + // Given — the marker reads fine but would be excluded from the copy + const outside = await mkdtemp(join(tmpdir(), "outside-")); + try { + await writeFile(join(outside, "real.md"), SKILL_MD); + await symlink(join(outside, "real.md"), join(dir, "SKILL.md")); + + // When / Then + await expect(discoverLocalSkills(dir)).rejects.toThrow( + /SKILL\.md must be a regular file \(found a symlink\)/, + ); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + + it("rejects a symlinked SKILL.md in a subdirectory", async () => { + // Given + const outside = await mkdtemp(join(tmpdir(), "outside-")); + try { + await writeFile(join(outside, "real.md"), SKILL_MD); + await mkdir(join(dir, "grill-me"), { recursive: true }); + await symlink( + join(outside, "real.md"), + join(dir, "grill-me", "SKILL.md"), + ); + + // When / Then + await expect(discoverLocalSkills(dir)).rejects.toThrow( + /must be a regular file/, + ); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + + it("rejects a directory named SKILL.md", async () => { + // Given + await mkdir(join(dir, "SKILL.md"), { recursive: true }); + + // When / Then + await expect(discoverLocalSkills(dir)).rejects.toThrow( + /must be a regular file \(found a directory\)/, + ); + }); + + it("rejects a missing path", async () => { + await expect( + discoverLocalSkills(join(dir, "does-not-exist")), + ).rejects.toThrow(/Directory not found/); + }); + + it("rejects a file path", async () => { + // Given + const filePath = join(dir, "SKILL.md"); + await writeFile(filePath, SKILL_MD); + + // When / Then + await expect(discoverLocalSkills(filePath)).rejects.toThrow( + /Not a directory/, + ); + }); + + it("explains when no SKILL.md exists at either level", async () => { + // Given + await writeFile(join(dir, "README.md"), "no skills here\n"); + + // When / Then + await expect(discoverLocalSkills(dir)).rejects.toThrow(/No skills found/); + }); +}); + +describe("skill path safety", () => { + it("rejects directory names that could escape the destination", () => { + for (const name of ["", ".", "..", "a/b", "a\\b"]) { + expect(() => assertSafeDirName(name)).toThrow(InvalidInputError); + } + }); + + it("accepts ordinary directory names", () => { + expect(() => assertSafeDirName("grill-me")).not.toThrow(); + }); + + it("rejects relative paths that climb out of the skill directory", () => { + for (const path of ["", "../secrets", "a/../../b", "/etc/passwd"]) { + expect(() => assertSafeRelativePath(path, "grill-me")).toThrow( + InvalidInputError, + ); + } + }); + + it("accepts ordinary nested paths", () => { + expect(() => + assertSafeRelativePath("references/api.md", "grill-me"), + ).not.toThrow(); + }); + + it("rejects resolved paths that land outside the skill root", () => { + expect(() => + assertWithinSkillRoot("/skills/grill-me", "/etc/passwd", "grill-me"), + ).toThrow(InvalidInputError); + // The root itself is not a file inside the skill. + expect(() => + assertWithinSkillRoot("/skills/grill-me", "/skills/grill-me", "grill-me"), + ).toThrow(InvalidInputError); + }); + + it("accepts resolved paths beneath the skill root", () => { + expect(() => + assertWithinSkillRoot( + "/skills/grill-me", + "/skills/grill-me/references/api.md", + "grill-me", + ), + ).not.toThrow(); + }); +}); + +describe("isBinary", () => { + it("flags a buffer containing a NUL byte", () => { + expect(isBinary(Buffer.from([0x50, 0x4e, 0x47, 0x00]))).toBe(true); + }); + + it("passes ordinary text through", () => { + expect(isBinary(Buffer.from("# Reference\n", "utf-8"))).toBe(false); + }); +}); diff --git a/packages/cli/tests/fixtures/local-skills/empty/README.md b/packages/cli/tests/fixtures/local-skills/empty/README.md new file mode 100644 index 000000000..5afa8e277 --- /dev/null +++ b/packages/cli/tests/fixtures/local-skills/empty/README.md @@ -0,0 +1 @@ +no skills here diff --git a/packages/cli/tests/fixtures/local-skills/multi/grill-me/SKILL.md b/packages/cli/tests/fixtures/local-skills/multi/grill-me/SKILL.md new file mode 100644 index 000000000..35e363e30 --- /dev/null +++ b/packages/cli/tests/fixtures/local-skills/multi/grill-me/SKILL.md @@ -0,0 +1,6 @@ +--- +name: grill-me +description: Interview the user relentlessly about a proposed change before any code is written. +--- + +Ask one question at a time. Recommend an answer for each. diff --git a/packages/cli/tests/fixtures/local-skills/multi/grill-me/references/api.md b/packages/cli/tests/fixtures/local-skills/multi/grill-me/references/api.md new file mode 100644 index 000000000..a208e363d --- /dev/null +++ b/packages/cli/tests/fixtures/local-skills/multi/grill-me/references/api.md @@ -0,0 +1,3 @@ +# Reference + +Bundled reference material the skill reads on demand. diff --git a/packages/cli/tests/fixtures/local-skills/multi/not-a-skill/README.md b/packages/cli/tests/fixtures/local-skills/multi/not-a-skill/README.md new file mode 100644 index 000000000..48cdce852 --- /dev/null +++ b/packages/cli/tests/fixtures/local-skills/multi/not-a-skill/README.md @@ -0,0 +1 @@ +placeholder diff --git a/packages/cli/tests/fixtures/local-skills/multi/tidy-up/SKILL.md b/packages/cli/tests/fixtures/local-skills/multi/tidy-up/SKILL.md new file mode 100644 index 000000000..cbeb39fab --- /dev/null +++ b/packages/cli/tests/fixtures/local-skills/multi/tidy-up/SKILL.md @@ -0,0 +1,6 @@ +--- +name: tidy-up +description: Pre-PR tidy-up review. +--- + +Check the diff for leftovers before opening a pull request. diff --git a/packages/cli/tests/fixtures/local-skills/single/SKILL.md b/packages/cli/tests/fixtures/local-skills/single/SKILL.md new file mode 100644 index 000000000..0c8a631b0 --- /dev/null +++ b/packages/cli/tests/fixtures/local-skills/single/SKILL.md @@ -0,0 +1,6 @@ +--- +name: deploy-check +description: Verify a deploy is safe before shipping it. +--- + +Confirm the build passes and the changelog is updated. diff --git a/packages/cli/tests/fixtures/local-skills/single/scripts/run.sh b/packages/cli/tests/fixtures/local-skills/single/scripts/run.sh new file mode 100644 index 000000000..8b2fe5434 --- /dev/null +++ b/packages/cli/tests/fixtures/local-skills/single/scripts/run.sh @@ -0,0 +1 @@ +echo hi diff --git a/packages/cli/tests/fixtures/local-skills/with-binary/SKILL.md b/packages/cli/tests/fixtures/local-skills/with-binary/SKILL.md new file mode 100644 index 000000000..9a7b5688c --- /dev/null +++ b/packages/cli/tests/fixtures/local-skills/with-binary/SKILL.md @@ -0,0 +1,6 @@ +--- +name: with-binary +description: A skill that bundles a binary asset. +--- + +The bundled asset cannot travel over the sandbox bridge. diff --git a/packages/cli/tests/fixtures/local-skills/with-binary/assets/logo.bin b/packages/cli/tests/fixtures/local-skills/with-binary/assets/logo.bin new file mode 100644 index 0000000000000000000000000000000000000000..7e0b61d4adc9f9244c20b13640e49eb3d18dd0d2 GIT binary patch literal 12 TcmWIWb7x>k%FIhFs$>8F6#@fm literal 0 HcmV?d00001