Skip to content
Merged
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# adlc-skills-cli

A generic CLI that wraps `npx skills add` and converts installed skills to slash commands — plus **event hooks** (`session_start`, `user_prompt_submit`, and more) that auto-trigger skills on any coding agent.
A generic CLI that wraps `npx skills add` and converts installed skills to slash commands — plus **event hooks** (`session_start`, `session_compact`, `user_prompt_submit`, and more) that auto-trigger skills on any coding agent.

Works with any skills repo: [adlc-team-skills](https://github.com/tikalk/adlc-team-skills), [mattpocock/skills](https://github.com/mattpocock/skills), [addyosmani/agent-skills](https://github.com/addyosmani/agent-skills), [obra/superpowers](https://github.com/obra/superpowers), or your own.

Expand Down Expand Up @@ -210,11 +210,12 @@ description: Orientation skill injected at session start
---
```

### 6 canonical events
### 7 canonical events

| Event | Fires when | Body path? | Script path? |
|-------|-----------|-----------|-------------|
| `session_start` | Agent session begins | yes | yes |
| `session_compact` | Harness compacts/summarizes history (post-compaction re-injection) | yes | yes |
| `user_prompt_submit` | User sends a prompt (payload via stdin) | yes | yes |
| `pre_tool_use` | Before a tool call | no | yes |
| `post_tool_use` | After a tool call | no | yes |
Expand Down
1 change: 1 addition & 0 deletions docs/events-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Repos without `.events.json` are handled gracefully — commands are generated,
| Event | Fires when | Payload (stdin) | Body path |
|-------|-----------|-----------------|-----------|
| `session_start` | Agent session begins | `{}` | yes |
| `session_compact` | Harness compacts/summarizes history (post-compaction re-injection) | `{}` | yes |
| `user_prompt_submit` | User sends a prompt | `{ "prompt": "..." }` | yes |
| `pre_tool_use` | Before a tool call | `{ "tool": "...", "args": {} }` | no (script only) |
| `post_tool_use` | After a tool call | `{ "tool": "...", "result": {} }` | no (script only) |
Expand Down
41 changes: 40 additions & 1 deletion src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,39 @@ async function cmdUpgrade(args, flags) {
const mode = flags.mode || null;
const isGlobal = flags.global || false;

if (flags.pull) {
// npx skills update doesn't re-copy files for local sources, so we
// read skills-lock.json for the source and re-install via npx skills add --copy
const lockPath = join(projectRoot, "skills-lock.json");
let pullSource = null;
if (existsSync(lockPath)) {
try {
const lock = JSON.parse(readFileSync(lockPath, "utf-8"));
const skillNames = flags.skill && flags.skill !== "*" ? [flags.skill] : Object.keys(lock.skills || {});
for (const name of skillNames) {
const entry = lock.skills?.[name];
if (entry?.source && !pullSource) pullSource = entry.source;
}
} catch {}
}
if (!pullSource) {
console.error("│ ✗ --pull requires skills-lock.json with source info (run 'add' first)");
return 1;
}
for (const agentKey of agents) {
const npxAgent = resolveNpxAgent(agentKey);
const npxArgs = ["skills", "add", pullSource, "-a", npxAgent, "--copy"];
if (flags.skill && flags.skill !== "*") { npxArgs.push("-s", flags.skill); }
npxArgs.push("-y");
console.log(`Pulling latest skills from ${pullSource} for ${agentKey}...`);
const result = spawnSync("npx", npxArgs, { stdio: "inherit", cwd: projectRoot });
if (result.status !== 0) {
console.error(`│ ✗ npx skills add failed for ${agentKey}`);
return result.status || 1;
}
}
}

for (const agentKey of agents) {
const agent = getAgent(agentKey);
if (!agent) continue;
Expand Down Expand Up @@ -371,6 +404,8 @@ function parseArgs(argv) {
flags.skill = rest[++i];
} else if (arg === "--copy") {
flags.copy = true;
} else if (arg === "--pull") {
flags.pull = true;
} else if (arg === "-y" || arg === "--yes") {
flags.yes = true;
} else if (arg === "--commands-dir") {
Expand All @@ -396,7 +431,8 @@ USAGE:

COMMANDS:
add <source> Install skills via npx skills + generate commands + events
upgrade Re-generate commands from currently-installed skills
upgrade [--pull] Re-generate commands from currently-installed skills
--pull: also re-install skills from source (via npx skills add)
remove Remove generated commands + event configs
status Show what's installed per agent
agents List supported agents
Expand All @@ -409,6 +445,7 @@ FLAGS:
--mode <mode> inline (default) | wrapper
--skill <name> Install/generate for one skill only (use '*' for all)
--copy Copy files instead of symlinking (passthrough to npx skills)
--pull Pull latest from source before regenerating (upgrade only)
-y, --yes Skip confirmation prompts

INSTALL:
Expand All @@ -419,6 +456,8 @@ EXAMPLES:
adlc-skills-cli add tikalk/adlc-team-skills -a opencode
adlc-skills-cli add mattpocock/skills -a claude-code -a opencode --no-events
adlc-skills-cli add tikalk/adlc-team-skills -a opencode --prefix adlc --skill team-setup
adlc-skills-cli upgrade --pull -a opencode
adlc-skills-cli upgrade --pull --skill team-boot -a opencode
adlc-skills-cli status
adlc-skills-cli agents
`);
Expand Down
49 changes: 41 additions & 8 deletions src/dispatcher.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@
// 2. Body path (superpowers model): output the skill's markdown body
// (frontmatter stripped). LLM-interpreted orientation/instructions.

import { readFileSync, readSync, existsSync, readdirSync, statSync, accessSync, constants } from "node:fs";
import { readFileSync, readSync, existsSync, readdirSync, statSync, accessSync, constants, realpathSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { isatty } from "node:tty";
import { join, resolve, dirname, sep, delimiter } from "node:path";
import { join, resolve, dirname, sep, delimiter, relative, isAbsolute, posix, win32 } from "node:path";
import { fileURLToPath } from "node:url";

const DEFAULT_TIMEOUT = 60;
Expand All @@ -36,7 +36,7 @@ const DEFAULT_TIMEOUT = 60;
// readFileSync(0) lets a hostile or buggy agent exhaust memory on every fire.
const MAX_STDIN_BYTES = 1024 * 1024; // 1 MiB

const BODY_INJECTION_EVENTS = new Set(["session_start", "user_prompt_submit"]);
const BODY_INJECTION_EVENTS = new Set(["session_start", "session_compact", "user_prompt_submit"]);

function main() {
const [,, event, skillName, skillsDirArg, timeoutArg, envelopeArg] = process.argv;
Expand Down Expand Up @@ -64,13 +64,19 @@ function main() {
process.exit(0); // fail-open
}

const content = readFileSync(skillPath, "utf-8");
let content;
try {
content = readFileSync(skillPath, "utf-8");
} catch {
process.stderr.write(`dispatcher: skill "${skillName}" is unreadable — skipping\n`);
process.exit(0); // fail-open
}
const { frontmatter, body } = parseFrontmatter(content);
const payload = readPayload();

// Path 1: script execution (spec-kit model) — applies to ALL events.
if (frontmatter && frontmatter.scripts) {
const argv = resolveScriptArgv(frontmatter.scripts, dirname(skillPath));
const argv = resolveScriptArgv(frontmatter.scripts, dirname(skillPath), projectRoot);
if (argv) {
try {
const result = spawnSync(argv[0], argv.slice(1), {
Expand Down Expand Up @@ -234,7 +240,34 @@ function findLauncher(names) {
return null;
}

function resolveScriptArgv(scriptsField, skillDir) {
// Confine a script token to the project tree (spec-kit #4133):
// Reject anchored tokens (absolute, drive, UNC) so resolve() can't
// discard the skill directory, and verify the resolved path stays
// inside the project root — including through symlink resolution so
// a relative token that resolves via a symlink out of the project
// cannot execute a host binary.
function confineScriptPath(base, token, projectRoot) {
if (posix.isAbsolute(token) || win32.isAbsolute(token)) return null;
const candidate = resolve(base, token);
const root = resolve(projectRoot);
// Resolve symlinks on both sides BEFORE comparing, so macOS /var → /private/var
// doesn't cause a false ".." in the relative path. Return the original
// (non-realpath) candidate for execution so the path stays consistent with
// what the caller expects.
let realCandidate, realRoot;
try {
realCandidate = realpathSync(candidate);
realRoot = realpathSync(root);
} catch {
realCandidate = candidate;
realRoot = root;
}
const rel = relative(realRoot, realCandidate);
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
return candidate;
}

function resolveScriptArgv(scriptsField, skillDir, projectRoot) {
// scripts: is either a YAML-style string ("sh: scripts/boot.sh\nps: ...")
// already parsed by our frontmatter parser into an object, or a raw string.
let scripts = scriptsField;
Expand All @@ -254,8 +287,8 @@ function resolveScriptArgv(scriptsField, skillDir) {
const tokens = shlexSplit(scriptCmd);
if (tokens.length === 0) return null;

const scriptPath = resolve(skillDir, tokens[0]);
if (!existsSync(scriptPath)) return null;
const scriptPath = confineScriptPath(skillDir, tokens[0], projectRoot);
if (scriptPath === null || !existsSync(scriptPath)) return null;

const rest = tokens.slice(1);

Expand Down
12 changes: 11 additions & 1 deletion src/registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ export function resolveNpxAgent(agent) {

export const CANONICAL_EVENTS = [
"session_start",
"session_compact",
"pre_tool_use",
"post_tool_use",
"session_end",
Expand All @@ -362,7 +363,7 @@ export const CANONICAL_EVENTS = [

// Events where the body-injection path (superpowers model) applies.
// Script path (spec-kit model) applies to ALL events.
export const BODY_INJECTION_EVENTS = new Set(["session_start", "user_prompt_submit"]);
export const BODY_INJECTION_EVENTS = new Set(["session_start", "session_compact", "user_prompt_submit"]);

// Context-injection envelope for hook stdout, per agent + canonical event.
//
Expand Down Expand Up @@ -420,6 +421,7 @@ export const EVENT_AGENTS = {
// session_end / stop → (no equivalent; skip)
canonical_to_native: {
session_start: "experimental.chat.messages.transform",
session_compact: null, // covered by the per-step messages.transform + live dedup (compaction self-heals)
pre_tool_use: "tool.execute.before",
post_tool_use: "tool.execute.after",
session_end: null,
Expand All @@ -434,6 +436,7 @@ export const EVENT_AGENTS = {
merge_key: "hooks",
canonical_to_native: {
session_start: "SessionStart",
session_compact: null, // unmatched SessionStart fires on ALL sources incl. compact — a compact-matched second entry would double-inject
pre_tool_use: "PreToolUse",
post_tool_use: "PostToolUse",
session_end: "SessionEnd",
Expand All @@ -448,6 +451,7 @@ export const EVENT_AGENTS = {
merge_key: "hooks",
canonical_to_native: {
session_start: "sessionStart",
session_compact: null, // no native compaction surface yet
pre_tool_use: "preToolUse",
post_tool_use: "postToolUse",
session_end: "sessionEnd",
Expand All @@ -468,6 +472,7 @@ export const EVENT_AGENTS = {
format: "copilot-json",
canonical_to_native: {
session_start: "sessionStart",
session_compact: null, // no native compaction surface yet
pre_tool_use: "preToolUse",
post_tool_use: "postToolUse",
session_end: "sessionEnd",
Expand All @@ -487,6 +492,7 @@ export const EVENT_AGENTS = {
format: "toml",
canonical_to_native: {
session_start: "SessionStart",
session_compact: null, // no native compaction surface yet
pre_tool_use: "PreToolUse",
post_tool_use: "PostToolUse",
session_end: "SessionEnd",
Expand All @@ -501,6 +507,7 @@ export const EVENT_AGENTS = {
merge_key: "hooks",
canonical_to_native: {
session_start: "SessionStart",
session_compact: null, // no native compaction surface yet
pre_tool_use: "BeforeTool",
post_tool_use: "AfterTool",
session_end: "SessionEnd",
Expand All @@ -523,6 +530,7 @@ export const EVENT_AGENTS = {
merge_key: "hooks",
canonical_to_native: {
session_start: "SessionStart",
session_compact: null, // no native compaction surface yet
pre_tool_use: "PreToolUse",
post_tool_use: "PostToolUse",
session_end: "SessionEnd",
Expand All @@ -542,6 +550,7 @@ export const EVENT_AGENTS = {
format: "json-root-nested",
canonical_to_native: {
session_start: "SessionStart",
session_compact: null, // no native compaction surface yet
pre_tool_use: "PreToolUse",
post_tool_use: "PostToolUse",
session_end: "SessionEnd",
Expand All @@ -563,6 +572,7 @@ export const EVENT_AGENTS = {
merge_key: "hooks",
canonical_to_native: {
session_start: "SessionStart",
session_compact: null, // no native compaction surface yet
pre_tool_use: "BeforeTool",
post_tool_use: "AfterTool",
session_end: "SessionEnd",
Expand Down
Loading
Loading