diff --git a/release-please-config.json b/release-please-config.json index 5aa86b8..899abd4 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -9,6 +9,11 @@ { "type": "generic", "path": "src/bin/akua.ts" + }, + { + "type": "json", + "path": "skills/akua/skill-package.json", + "jsonpath": "$.version" } ] } diff --git a/skills/akua/SKILL.md b/skills/akua/SKILL.md new file mode 100644 index 0000000..2e08a6b --- /dev/null +++ b/skills/akua/SKILL.md @@ -0,0 +1,60 @@ +--- +name: akua +description: Guide agents working with Akua documentation, workspaces, authentication, generated CLI commands, structured output, and approval-safe operations. Use for Akua setup, inspection, troubleshooting, or change requests. +--- + +# Work with Akua + +Use the most authoritative available surface for each kind of information. Keep discovery and reads separate from mutations, and report what actually happened. + +## Choose the authoritative surface + +1. Prefer the Akua docs MCP for current product and API documentation. Treat remembered documentation, copied examples, and model knowledge as potentially stale. +2. Use the Akua platform MCP and its Code Mode for authoritative workspace state. Read the relevant objects before drawing conclusions or proposing changes. +3. Use the canonical `akua` CLI when it is present and the needed behavior is released. Inspect the installed surface instead of assuming a command or install channel exists. + +If an authoritative surface is unavailable, say which check could not be made. Do not substitute an inferred live state. + +## Inspect the installed CLI + +Start with read-only discovery: + +```sh +command -v akua +akua --version +akua --help --output agent +akua auth status --output agent +akua commands --output agent +``` + +Use `akua commands --resource ` or `akua commands --operation-id ` to narrow the generated public command registry. Registry presence describes the generated surface; it does not prove that execution is implemented. If a command reports that it is not implemented, return to the docs or platform MCP instead of inventing flags or claiming success. + +Agents must never place authentication tokens in tool arguments, process argv, transcripts, or logs. When authentication setup is requested, instruct the human to run `akua auth login --token ` privately and locally themselves. Afterward, agents may run `akua auth status --output agent` to confirm the effective source without revealing the credential. Agents may run `akua auth logout` only when explicitly requested. + +## Select structured output deliberately + +Akua supports `human`, `agent`, `json`, and `quiet` output modes. + +- Set `AGENT=true` or `AGENT=` to select agent-oriented output by default. +- Use `--output agent` for compact, self-describing AGENT output with observations, data, and next steps. +- Use `--json` or `--output json` when a program must parse the result. +- Use `--quiet` only when the exit status is sufficient. + +Check the exit status and structured error payload before reporting success. Do not parse human prose when a structured mode is available. + +## Preserve approvals and mutation boundaries + +- Read current workspace state before planning a change. Use explicit workspace and resource identifiers. +- Do not create, update, delete, deploy, or resolve an approval request unless the human explicitly authorized that action. +- Preserve human approval gates. Do not add `--yes`, `--force`, confirmation responses, or approval resolutions merely to make automation continue. +- In agent, JSON, quiet, CI, or other non-interactive environments, never assume an interactive prompt will protect a mutation. Treat a confirmation-required or unsafe-mutation refusal as a stop condition. +- When a dry-run or preview is documented and available, inspect it before asking for mutation approval. Do not claim a preview changed live state. +- After an authorized mutation, read the authoritative state again and report identifiers, observed status, and any remaining approval or wait step. + +Generated command discovery, desired configuration, and a submitted request are not evidence that live state changed. State clearly when execution is unavailable, refused, awaiting approval, or unverified. + +## Avoid legacy and unavailable paths + +Use only the `akua` executable. Do not suggest CNAP-era binaries, commands, module paths, domains, or compatibility aliases. + +Do not claim that Akua is publicly installable through npm, Homebrew, `npx`, a skills marketplace, or another channel unless the current Akua docs MCP or a published release explicitly confirms it. If `akua` is not installed, report that fact and use the available MCP surfaces; do not invent installation instructions. diff --git a/skills/akua/skill-package.json b/skills/akua/skill-package.json new file mode 100644 index 0000000..1a11e40 --- /dev/null +++ b/skills/akua/skill-package.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "name": "akua", + "version": "0.7.0", + "provenance": { + "repository": "https://github.com/akua-dev/cli", + "path": "skills/akua/SKILL.md", + "revision_source": "git" + }, + "files": [ + { + "path": "SKILL.md", + "sha256": "abec4823b7e9ea42d9e261ce598442101524e8309a01377f494cf54cdac6c5c3" + } + ] +} diff --git a/test/agent-skill.test.ts b/test/agent-skill.test.ts new file mode 100644 index 0000000..9f6e4a1 --- /dev/null +++ b/test/agent-skill.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { basename, dirname } from "node:path"; + +const SKILL_PATH = "skills/akua/SKILL.md"; +const PACKAGE_PATH = "skills/akua/skill-package.json"; +const SKILL_NAME = "akua"; +const SOURCE_REPOSITORY = "https://github.com/akua-dev/cli"; +const FRONTMATTER_FIELDS = new Set([ + "name", + "description", + "license", + "compatibility", + "metadata", + "allowed-tools", +]); + +interface SkillPackage { + schema_version: number; + name: string; + version: string; + provenance: { + repository: string; + path: string; + revision_source: string; + }; + files: Array<{ + path: string; + sha256: string; + }>; +} + +describe("canonical Akua agent skill", () => { + test("is a valid, release-versioned Agent Skills artifact", () => { + const skill = readFileSync(SKILL_PATH, "utf8"); + const skillPackage = readSkillPackage(PACKAGE_PATH); + const rootPackage = JSON.parse(readFileSync("package.json", "utf8")) as { version: string }; + + expect(() => validateFrontmatter(SKILL_PATH, skill)).not.toThrow(); + expect(() => validateSkillPackage(skillPackage, skill, rootPackage.version)).not.toThrow(); + }); + + test("directs authentication setup through the human without exposing tokens", () => { + const skill = readFileSync(SKILL_PATH, "utf8"); + + expect(skill).toContain( + "Agents must never place authentication tokens in tool arguments, process argv, transcripts, or logs.", + ); + expect(skill).toContain( + "instruct the human to run `akua auth login --token ` privately and locally themselves", + ); + expect(skill).toContain("agents may run `akua auth status --output agent`"); + }); + + test("accepts standard optional frontmatter fields in any key order", () => { + const reordered = skillWithFrontmatter(`allowed-tools: Bash(git:*) Read +metadata: + author: akua-dev + version: "1.0" +description: Use when working with Akua. +license: Apache-2.0 +name: ${SKILL_NAME} +compatibility: Requires Bun 1.3.7 or newer`); + + expect(() => validateFrontmatter(SKILL_PATH, reordered)).not.toThrow(); + }); + + test("rejects unknown frontmatter fields", () => { + const unknown = skillWithFrontmatter(`name: ${SKILL_NAME} +description: Use when working with Akua. +version: 1.0.0`); + + expect(() => validateFrontmatter(SKILL_PATH, unknown)).toThrow("unknown frontmatter field: version"); + }); + + test("rejects invalid optional frontmatter field values", () => { + const invalidFields = [ + "license: []", + "compatibility: 1", + `compatibility: ${"x".repeat(501)}`, + "metadata: []", + "metadata:\n version: 1", + "allowed-tools: []", + ]; + + for (const field of invalidFields) { + const invalid = skillWithFrontmatter(`name: ${SKILL_NAME} +description: Use when working with Akua. +${field}`); + + expect(() => validateFrontmatter(SKILL_PATH, invalid)).toThrow(); + } + }); + + test("rejects invalid descriptions", () => { + const missing = skillWithFrontmatter(`name: ${SKILL_NAME}`); + const blank = skillWithFrontmatter(`name: ${SKILL_NAME} +description: " "`); + + expect(() => validateFrontmatter(SKILL_PATH, missing)).toThrow("description must be a non-empty string"); + expect(() => validateFrontmatter(SKILL_PATH, blank)).toThrow("description must be a non-empty string"); + }); + + test("rejects invalid YAML frontmatter syntax", () => { + const invalidYaml = `---\nname: ${SKILL_NAME}\ndescription: [unterminated\n---\n# Akua\n`; + + expect(() => validateFrontmatter(SKILL_PATH, invalidYaml)).toThrow(); + }); + + test("rejects skill path and name drift", () => { + const drifted = `---\nname: another-skill\ndescription: Use when working with Akua.\n---\n# Akua\n`; + + expect(() => validateFrontmatter(SKILL_PATH, drifted)).toThrow("must match its parent directory"); + }); + + test("rejects task-shaped public skill identities", () => { + const taskShapedPath = "skills/agent-skill-compliance-task/SKILL.md"; + const taskShaped = `---\nname: agent-skill-compliance-task\ndescription: Use when working with Akua.\n---\n# Akua\n`; + + expect(() => validateFrontmatter(taskShapedPath, taskShaped)).toThrow("must use the canonical Akua identity"); + }); + + test("rejects stale version and provenance metadata", () => { + const skill = validSkill(); + const staleVersion = { ...validSkillPackage(skill), version: "0.0.0" }; + const staleProvenance = { + ...validSkillPackage(skill), + provenance: { ...validSkillPackage(skill).provenance, repository: "https://github.com/akua-dev/skills" }, + }; + + expect(() => validateSkillPackage(staleVersion, skill, "1.2.3")).toThrow("version must match"); + expect(() => validateSkillPackage(staleProvenance, skill, "1.2.3")).toThrow("provenance must identify"); + }); + + test("rejects non-deterministic or stale package contents", () => { + const skill = validSkill(); + const generatedAt = { ...validSkillPackage(skill), generated_at: new Date().toISOString() }; + const staleDigest = { + ...validSkillPackage(skill), + files: [{ path: "SKILL.md", sha256: "0".repeat(64) }], + }; + + expect(() => validateSkillPackage(generatedAt, skill, "1.2.3")).toThrow("package keys"); + expect(() => validateSkillPackage(staleDigest, skill, "1.2.3")).toThrow("deterministic SKILL.md digest"); + }); + + test("release automation keeps skill package version metadata current", () => { + const config = JSON.parse(readFileSync("release-please-config.json", "utf8")) as { + packages?: Record; + }; + + expect(config.packages?.["."]?.["extra-files"]).toContainEqual({ + type: "json", + path: PACKAGE_PATH, + jsonpath: "$.version", + }); + }); +}); + +function validateFrontmatter(path: string, source: string): void { + const match = source.match(/^---\n([\s\S]*?)\n---\n([\s\S]+)$/); + if (!match) { + throw new Error("SKILL.md must contain YAML frontmatter followed by Markdown"); + } + + const parsed: unknown = Bun.YAML.parse(match[1]); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("frontmatter must be a YAML mapping"); + } + + const fields = parsed as Record; + const unknownFields = Object.keys(fields).filter((field) => !FRONTMATTER_FIELDS.has(field)); + if (unknownFields.length > 0) { + throw new Error(`unknown frontmatter field: ${unknownFields.join(", ")}`); + } + + const name = fields.name; + const description = fields.description; + if (typeof name !== "string" || !name) { + throw new Error("skill name must be a non-empty string"); + } + if (typeof description !== "string" || !description.trim()) { + throw new Error("skill description must be a non-empty string"); + } + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) || name.length > 64) { + throw new Error("skill name must follow the Agent Skills naming rules"); + } + if (name !== basename(dirname(path))) { + throw new Error("skill name must match its parent directory"); + } + if (name !== SKILL_NAME) { + throw new Error("skill name must use the canonical Akua identity"); + } + if (description.length > 1024) { + throw new Error("skill description must not exceed 1024 characters"); + } + + validateOptionalString(fields, "license"); + validateOptionalString(fields, "allowed-tools"); + + const compatibility = validateOptionalString(fields, "compatibility"); + if (compatibility !== undefined && (compatibility.length === 0 || compatibility.length > 500)) { + throw new Error("skill compatibility must contain 1-500 characters"); + } + + const metadata = fields.metadata; + if (metadata !== undefined) { + if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) { + throw new Error("skill metadata must be a string-to-string mapping"); + } + if (Object.values(metadata).some((value) => typeof value !== "string")) { + throw new Error("skill metadata must be a string-to-string mapping"); + } + } +} + +function validateOptionalString(fields: Record, field: string): string | undefined { + const value = fields[field]; + if (value !== undefined && typeof value !== "string") { + throw new Error(`skill ${field} must be a string`); + } + return value; +} + +function validateSkillPackage(skillPackage: SkillPackage, skill: string, rootVersion: string): void { + if (Object.keys(skillPackage).join(",") !== "schema_version,name,version,provenance,files") { + throw new Error("package keys must be stable and exclude generated timestamps"); + } + if (skillPackage.schema_version !== 1 || skillPackage.name !== SKILL_NAME) { + throw new Error("package schema and name must identify the canonical skill"); + } + if (skillPackage.version !== rootVersion) { + throw new Error("skill package version must match the CLI release version"); + } + if ( + JSON.stringify(skillPackage.provenance) !== + JSON.stringify({ repository: SOURCE_REPOSITORY, path: SKILL_PATH, revision_source: "git" }) + ) { + throw new Error("package provenance must identify the CLI-owned source and containing git revision"); + } + + const expectedFiles = [{ path: "SKILL.md", sha256: sha256(skill) }]; + if (JSON.stringify(skillPackage.files) !== JSON.stringify(expectedFiles)) { + throw new Error("package files must contain the deterministic SKILL.md digest"); + } +} + +function readSkillPackage(path: string): SkillPackage { + return JSON.parse(readFileSync(path, "utf8")) as SkillPackage; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function validSkill(): string { + return skillWithFrontmatter(`name: ${SKILL_NAME}\ndescription: Use when working with Akua.`); +} + +function skillWithFrontmatter(frontmatter: string): string { + return `---\n${frontmatter}\n---\n# Akua\n`; +} + +function validSkillPackage(skill: string): SkillPackage { + return { + schema_version: 1, + name: SKILL_NAME, + version: "1.2.3", + provenance: { + repository: SOURCE_REPOSITORY, + path: SKILL_PATH, + revision_source: "git", + }, + files: [{ path: "SKILL.md", sha256: sha256(skill) }], + }; +} diff --git a/test/release-please-config.test.ts b/test/release-please-config.test.ts index 0084180..5655acb 100644 --- a/test/release-please-config.test.ts +++ b/test/release-please-config.test.ts @@ -10,6 +10,7 @@ interface ReleasePleaseConfig { "extra-files"?: Array<{ type?: string; path?: string; + jsonpath?: string; }>; }>; } @@ -27,6 +28,11 @@ describe("release-please configuration", () => { type: "generic", path: "src/bin/akua.ts", }, + { + type: "json", + path: "skills/akua/skill-package.json", + jsonpath: "$.version", + }, ], }); expect(config["include-component-in-tag"]).toBe(false);