Skip to content
6 changes: 5 additions & 1 deletion .github/aw/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ find "${GITHUB_WORKSPACE}" -name "SKILL.md" -maxdepth 6

## Frontmatter `skills:` (Preferred for External Skills)

Declare external skills to install at activation time with the top-level `skills:` array.
Declare skills to install at activation time with the top-level `skills:` array.
The compiler emits the activation steps, prepares the required `gh` support, installs each skill, and wires it into the engine.
Do **not** add manual `gh` setup or `gh skill install` steps for this.

Expand All @@ -27,6 +27,9 @@ skills:
# Shared auth via the workflow activation token
- mattpocock/skills/tdd@801dca688564c529fa84f247f64472520d9ebe28

# Local skill path for development (installed with --from-local)
- .github/skills/my-skill

# Per-skill token for a private skill repository
- skill: mattpocock/skills/diagnosing-bugs@801dca688564c529fa84f247f64472520d9ebe28
github-token: ${{ secrets.MATT_SKILLS_PAT || secrets.GITHUB_TOKEN }}
Expand All @@ -39,6 +42,7 @@ skills:
```

- Static references must be pinned to a full 40-character lowercase commit SHA; `${{ ... }}` expressions are allowed in the ref position and resolved at runtime.
- Local paths (for example, `skills/my-skill` or `.github/skills/my-skill`) are supported for local development and are installed via `--from-local`.
- Object entries set per-skill auth via `github-token` or `github-app`.
- Use `skills:` for external skill installs and `imports:` for prompt/context files you want merged into the workflow.

Expand Down
27 changes: 25 additions & 2 deletions actions/setup/js/install_frontmatter_skills.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,43 @@ function parseSkillSpecs(rawSkills) {
* @typedef {{args: string[]; displaySpec: string}} SkillInstallCommand
*/

/**
* Reports whether a skill spec is a local path reference — one that has no
* "@" separator and is not a GitHub Actions expression. Local refs are
* installed with --from-local instead of fetching from a remote repository.
*
* @param {string} skillSpec
* @returns {boolean}
*/
function isLocalSkillRef(skillSpec) {
return skillSpec !== "" && !skillSpec.startsWith("${{") && !skillSpec.includes("@");
}

/**
* @param {string} skillSpec
* @param {string} skillsDst
* @param {string} skillInstallAgent
* @returns {SkillInstallCommand}
*/
function buildSkillInstallCommand(skillSpec, skillsDst, skillInstallAgent = "") {
const agentArgs = skillInstallAgent ? ["--agent", skillInstallAgent] : [];

// Local path reference: install from the filesystem using --from-local.
// The skill name is derived from the last path component.
if (isLocalSkillRef(skillSpec)) {
const skillName = skillSpec.replace(/\\/g, "/").split("/").filter(Boolean).pop() || skillSpec;
return {
displaySpec: skillSpec,
args: ["skill", "install", skillSpec, skillName, "--from-local", ...agentArgs, "--dir", skillsDst, "--force"],
Comment on lines +46 to +50
};
}

const atIndex = skillSpec.lastIndexOf("@");
const hasPin = atIndex >= 0;
const skillBase = hasPin ? skillSpec.slice(0, atIndex) : skillSpec;
const skillRef = hasPin ? skillSpec.slice(atIndex + 1) : "";
const parts = skillBase.split("/");
const pinArgs = skillRef ? ["--pin", skillRef] : [];
const agentArgs = skillInstallAgent ? ["--agent", skillInstallAgent] : [];

if (parts.length >= 3) {
return {
Expand Down Expand Up @@ -189,4 +212,4 @@ async function main() {
await writeSkillSummary(skillDir, skills, installedSkillCount, failures);
}

module.exports = { main, parseSkillSpecs, buildSkillInstallCommand, countInstalledSkillFiles, appendSkillInstallFailure };
module.exports = { main, parseSkillSpecs, buildSkillInstallCommand, isLocalSkillRef, countInstalledSkillFiles, appendSkillInstallFailure };
43 changes: 41 additions & 2 deletions actions/setup/js/install_frontmatter_skills.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,37 @@ describe("install_frontmatter_skills", () => {
]);
});

it("omits --pin when the resolved skill spec is unpinned", () => {
expect(script.buildSkillInstallCommand("githubnext/skills/review/security", "/tmp/gh-aw/.claude/skills").args).toEqual(["skill", "install", "githubnext/skills", "review/security", "--dir", "/tmp/gh-aw/.claude/skills", "--force"]);
it("treats specs without @ as local path refs (installs with --from-local)", () => {
// Any spec without "@" and without "${{" is a local path reference.
// Remote refs must always be pinned (owner/repo@sha).
expect(script.buildSkillInstallCommand("skills/review/security", "/tmp/gh-aw/.claude/skills").args).toEqual(["skill", "install", "skills/review/security", "security", "--from-local", "--dir", "/tmp/gh-aw/.claude/skills", "--force"]);
});

it("installs local path reference using --from-local", () => {
expect(script.buildSkillInstallCommand("skills/rig", "/tmp/gh-aw/.claude/skills").args).toEqual(["skill", "install", "skills/rig", "rig", "--from-local", "--dir", "/tmp/gh-aw/.claude/skills", "--force"]);
expect(script.buildSkillInstallCommand(".github/skills/my-skill", "/tmp/gh-aw/.claude/skills", "claude-code").args).toEqual([
"skill",
"install",
".github/skills/my-skill",
"my-skill",
"--from-local",
"--agent",
"claude-code",
"--dir",
"/tmp/gh-aw/.claude/skills",
"--force",
]);
expect(script.buildSkillInstallCommand("./skills/my-skill", "/tmp/gh-aw/.claude/skills").args).toEqual(["skill", "install", "./skills/my-skill", "my-skill", "--from-local", "--dir", "/tmp/gh-aw/.claude/skills", "--force"]);
});

it("isLocalSkillRef returns true for local paths and false for remote or expression specs", () => {
expect(script.isLocalSkillRef("skills/rig")).toBe(true);
expect(script.isLocalSkillRef(".github/skills/my-skill")).toBe(true);
expect(script.isLocalSkillRef("./skills/my-skill")).toBe(true);
expect(script.isLocalSkillRef("my-skill")).toBe(true);
expect(script.isLocalSkillRef("owner/repo@abc123")).toBe(false);
expect(script.isLocalSkillRef("${{ inputs.skill_ref }}")).toBe(false);
expect(script.isLocalSkillRef("")).toBe(false);
});

it("reads skill specs from the env var and installs them at runtime", async () => {
Expand Down Expand Up @@ -148,4 +177,14 @@ describe("install_frontmatter_skills", () => {
expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to install skill 'bad/repo@abc123'"));
expect(global.core.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("<details open>"));
});

it("installs local path references using --from-local at runtime", async () => {
process.env.GH_AW_SKILL_DIR = ".claude/skills";
process.env.GH_AW_GH_SKILL_AGENT_NAME = "claude-code";
process.env.GH_AW_FRONTMATTER_SKILLS = "skills/rig";

await script.main();

expect(global.exec.exec).toHaveBeenCalledWith("gh", ["skill", "install", "skills/rig", "rig", "--from-local", "--agent", "claude-code", "--dir", "/tmp/gh-aw/.claude/skills", "--force"]);
});
});
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ See [Using MCPs](/gh-aw/guides/mcps/).

### If my agent can use a skill, can agentic workflows use it too?

Usually yes. Prefer frontmatter [`skills:`](/gh-aw/reference/frontmatter/#frontmatter-skills-skills) to install external skills for workflow runs. Use [imports](/gh-aw/reference/imports/) for workflow-level config and prompts, and [APM (Agent Package Manager)](https://microsoft.github.io/apm/) for reusable package distribution of skills and other agent primitives. See [APM Dependencies](/gh-aw/reference/dependencies/).
Usually yes. Prefer frontmatter [`skills:`](/gh-aw/reference/frontmatter/#frontmatter-skills-skills) to install skills for workflow runs: use local paths (for example, `skills/name` or `.github/skills/name`) during development and pinned external references for published workflows. Use [imports](/gh-aw/reference/imports/) for workflow-level config and prompts, and [APM (Agent Package Manager)](https://microsoft.github.io/apm/) for reusable package distribution of skills and other agent primitives. See [APM Dependencies](/gh-aw/reference/dependencies/).

### The `plugins:` or `dependencies:` field I was using is gone - how do I install agent plugins now?

Expand Down
11 changes: 6 additions & 5 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,12 @@ tracker-id: "example-value"
labels: []
# Array of strings

# Optional list of external skill references to install during activation.
# Supports repository-wide installs (`owner/repo@<sha>`) and path-scoped installs
# (`owner/repo/skill/path@<sha>`). Static references must be pinned to a full
# 40-character lowercase commit SHA. GitHub Actions expressions (`${{ ... }}`) are
# also accepted and are evaluated at runtime. Entries may also be objects to
# Optional list of skill references to install during activation.
# Supports local development paths (`skills/name`, `.github/skills/name`),
# repository-wide installs (`owner/repo@<sha>`), and path-scoped installs
# (`owner/repo/skill/path@<sha>`). Static external references must be pinned to
# a full 40-character lowercase commit SHA. GitHub Actions expressions (`${{ ... }}`)
# are also accepted and are evaluated at runtime. Entries may also be objects to
# configure per-skill authentication via github-token or github-app.
# (optional)
skills: []
Expand Down
5 changes: 3 additions & 2 deletions docs/src/content/docs/reference/frontmatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,12 @@ See [Tools](/gh-aw/reference/tools/) for complete documentation on built-in tool

### Frontmatter Skills (`skills:`)

Installs external Copilot skills in the activation job before the agent runs.
Each entry must be pinned to a 40-character lowercase commit SHA.
Installs Copilot skills in the activation job before the agent runs.

Supported entry formats:

- String form (shared authentication):
- `skills/name` or `.github/skills/name` (local development path; installed with `--from-local`)
- `owner/repo@<40-char-sha>`
- `owner/repo/skill/path@<40-char-sha>`
- Object form (per-skill authentication):
Expand All @@ -219,6 +219,7 @@ Supported entry formats:
`github-token` and `github-app` are mutually exclusive for each object entry.
`github-token` must be an expression such as `${{ secrets.NAME }}` or
`${{ needs.auth.outputs.token }}`.
Static external references must be pinned to a 40-character lowercase commit SHA.

```yaml wrap
skills:
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/reference/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -1050,11 +1050,11 @@ Markdown files with YAML frontmatter stored in `.github/skills/` for repository-

### Frontmatter Skills (`skills:`)

A frontmatter field that declares external skill repositories to install in the activation job before the agent runs. Each entry is a skill specification string (e.g., `owner/repo`, `owner/repo/path@sha`) pointing to a `.github/skills/` skill directory. The activation job installs each skill using the `gh skill install` command. When a skill fails to install, the failure is captured in the agent failure context and surfaces in failure issue/comment reports. Requires a recent version of the `gh` CLI. See [Skill Install Failure](#skill-install-failure) for error handling.
A frontmatter field that declares skills to install in the activation job before the agent runs. Entries can be local development paths (for example, `skills/name` or `.github/skills/name`) or external skill specs (for example, `owner/repo` or `owner/repo/path@sha`) pointing to a `.github/skills/` skill directory. Local paths install via `gh skill install ... --from-local`, while static external references must be pinned to a full 40-character lowercase commit SHA. When a skill fails to install, the failure is captured in the agent failure context and surfaces in failure issue/comment reports. Requires a recent version of the `gh` CLI. See [Skill Install Failure](#skill-install-failure) for error handling.

### Skill Install Failure

A failure category reported when one or more frontmatter skills could not be installed before the agent ran. Triggered by invalid skill references, inaccessible repositories, insufficient token permissions, or unsupported `gh` CLI versions. When skill install failures occur, they are captured by the `collect-skill-install-failures` activation step and included in the agent failure issue or comment via the `{skill_install_failure_context}` template. Resolve by verifying the skill reference format (`owner/repo` or `owner/repo/skill/path@sha`), confirming the token has read access to the skill repository, and ensuring a recent `gh` CLI version is available. See [Safe Outputs Reference](/gh-aw/reference/safe-outputs/).
A failure category reported when one or more frontmatter skills could not be installed before the agent ran. Triggered by invalid skill references, inaccessible repositories, insufficient token permissions, or unsupported `gh` CLI versions. When skill install failures occur, they are captured by the `collect-skill-install-failures` activation step and included in the agent failure issue or comment via the `{skill_install_failure_context}` template. Resolve by verifying the skill reference format (local path such as `skills/name` or external reference such as `owner/repo` / `owner/repo/skill/path@sha`), confirming the token has read access to external skill repositories when applicable, and ensuring a recent `gh` CLI version is available. See [Safe Outputs Reference](/gh-aw/reference/safe-outputs/).

### Fine-grained Personal Access Token

Expand Down
4 changes: 4 additions & 0 deletions pkg/cli/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,10 @@ func TestAuditCachingBehavior(t *testing.T) {
t.Errorf("Expected workflow name %s, got %s", summary.Run.WorkflowName, loadedSummary.Run.WorkflowName)
}

if err := markArtifactDownloaded(runOutputDir, string(ArtifactSetAll)); err != nil {
t.Fatalf("markArtifactDownloaded: %v", err)
}

// Verify that downloadRunArtifacts skips download when valid summary exists
// This is tested by checking that the function returns without error
// and doesn't attempt to call `gh run download`
Expand Down
3 changes: 3 additions & 0 deletions pkg/cli/data/agentic_workflows_fallback_aw_files.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"evals.md",
"experiments.md",
"github-agentic-workflows.md",
"github-mcp-server-pagination.md",
"github-mcp-server.md",
"instructions.md",
"linter-workflows.md",
Expand Down Expand Up @@ -48,10 +49,12 @@
"subagents.md",
"syntax-agentic.md",
"syntax-core.md",
"syntax-engine.md",
"syntax-tools-imports.md",
"syntax.md",
"test-coverage.md",
"test-expression.md",
"token-optimization-caching-budgets.md",
"token-optimization.md",
"triggers.md",
"update-agentic-workflow.md",
Expand Down
17 changes: 8 additions & 9 deletions pkg/cli/view_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,10 @@ func buildViewRunDir(t *testing.T) string {
t.Fatalf("WriteFile events.jsonl: %v", err)
}

// Mark the directory as already downloaded so downloadRunArtifacts skips
// network calls (it returns early when the dir is non-empty and has no cached
// summary — it just skips the download and lets the caller process what's there).
// Mark all artifacts as downloaded so downloadRunArtifacts skips network calls.
if err := markArtifactDownloaded(runDir, string(ArtifactSetAll)); err != nil {
t.Fatalf("markArtifactDownloaded: %v", err)
}
return dir
}

Expand Down Expand Up @@ -272,17 +273,15 @@ func TestViewWorkflowRun_WithSafeOutputs_ShowsSection(t *testing.T) {
}

func TestViewWorkflowRun_EmptyDir_WarnsAndReturnsNil(t *testing.T) {
// A run dir that is non-empty (so downloadRunArtifacts skips the network call)
// but contains no JSONL files → no events warning, no error.
// A run dir marked as downloaded, but containing no JSONL files
// -> no events -> warning, no error.
logsDir := t.TempDir()
runDir := filepath.Join(logsDir, "run-1111")
if err := os.MkdirAll(runDir, 0755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
// Place a dummy file so the directory is not empty; downloadRunArtifacts will
// skip the download when the dir is non-empty (no valid cached summary).
if err := os.WriteFile(filepath.Join(runDir, "placeholder.txt"), []byte("x"), 0600); err != nil {
t.Fatalf("WriteFile placeholder: %v", err)
if err := markArtifactDownloaded(runDir, string(ArtifactSetAll)); err != nil {
t.Fatalf("markArtifactDownloaded: %v", err)
}

opts := ViewOptions{
Expand Down
14 changes: 12 additions & 2 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
},
"skills": {
"type": "array",
"description": "Optional list of external skill references to install during activation. Supports repository-wide installs (`owner/repo@<sha>`) and path-scoped installs (`owner/repo/skill/path@<sha>`). Static references must be pinned to a full 40-character lowercase commit SHA. GitHub Actions expressions (`${{ ... }}`) are also accepted and are evaluated at runtime. Entries may also be objects to configure per-skill authentication via github-token or github-app.",
"description": "Optional list of skill references to install during activation. Supports remote repository-wide installs (`owner/repo@<sha>`), remote path-scoped installs (`owner/repo/skill/path@<sha>`), and local path references (e.g. `skills/rig` or `.github/skills/my-skill`). Remote static references must be pinned to a full 40-character lowercase commit SHA. Local paths are installed with --from-local at runtime and are rewritten to a remote repospec by `gh aw add`. GitHub Actions expressions (`${{ ... }}`) are also accepted and are evaluated at runtime. Entries may also be objects to configure per-skill authentication via github-token or github-app.",
"items": {
"oneOf": [
{
Expand All @@ -75,6 +75,11 @@
"pattern": "^\\$\\{\\{.+\\}\\}$",
"description": "GitHub Actions expression that resolves to a full skill reference string at runtime."
},
{
"type": "string",
"pattern": "^(?:\\./)?(?:\\.[A-Za-z0-9_-][A-Za-z0-9_.-]*|[A-Za-z0-9_-][A-Za-z0-9_.-]*)(?:\\/(?:\\.[A-Za-z0-9_-][A-Za-z0-9_.-]*|[A-Za-z0-9_-][A-Za-z0-9_.-]*))*$",
"description": "Local path to a skill directory relative to the repository root. Installed with --from-local at runtime. Rewritten to a remote repospec by gh aw add."
},
{
"type": "object",
"description": "Object-form skill reference with per-skill authentication.",
Expand All @@ -94,6 +99,11 @@
{
"type": "string",
"pattern": "^\\$\\{\\{.+\\}\\}$"
},
{
"type": "string",
"pattern": "^(?:\\./)?(?:\\.[A-Za-z0-9_-][A-Za-z0-9_.-]*|[A-Za-z0-9_-][A-Za-z0-9_.-]*)(?:\\/(?:\\.[A-Za-z0-9_-][A-Za-z0-9_.-]*|[A-Za-z0-9_-][A-Za-z0-9_.-]*))*$",
"description": "Local path to a skill directory relative to the repository root."
}
],
"description": "Skill reference string in the same format accepted by string-form entries."
Expand All @@ -110,7 +120,7 @@
}
]
},
"examples": [["githubnext/awesome-skills@1f181b37d3fe5862ab590648f25a292e345b5de6"], ["githubnext/awesome-skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6"]]
"examples": [["githubnext/awesome-skills@1f181b37d3fe5862ab590648f25a292e345b5de6"], ["githubnext/awesome-skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6"], ["skills/rig"]]
},
"metadata": {
"type": "object",
Expand Down
Loading
Loading