Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,32 @@ The reader side is `src/lib/logs.ts` (`codevhub logs`): bare mode prints the mos

Testing: logging is a silent no-op until `initLogging` runs, so ordinary tests need no setup and never write files. Tests that assert documents stub `CODEV_LOG_DIR`, call `initLogging(cmd, [], { installProcessHooks: false })` (so vitest's process stays free of our exit/crash listeners), and `resetLogging()` in `afterEach`. Related: `login()`'s force-login probe is keyed off `~/.codev-hub/auth.json` — not the `~/.codev-hub` dir — precisely because the logger creates `~/.codev-hub/logs` at the entry of every command.

## Where skills go (`codevhub skill pull`)

`src/lib/skill-dirs.ts` owns the one fact this feature turns on: **which directory each agent reads.** Establish it from the agents' own source, never from their docs — the table baked into the CoDev Code bundle lists only the `~/…` paths and omits the project-scope walk, which is the opposite of what the code does.

The authority is `packages/opencode/src/skill/index.ts#discoverSkills` (CoDev Code / OpenCode — same module): `externalDirs = [".claude", ".agents"]`, scanned under `global.home` **and** walked from cwd up to the worktree root. Codex reads `.agents/skills` at both scopes (and `$HOME`); Claude Code reads `.claude/skills` at both. So:

| | `.agents/skills` | `.claude/skills` |
|---|---|---|
| Codex | yes | no |
| CoDev Code / OpenCode | yes | yes |
| Claude Code | no | yes |

Neither directory alone covers all four; together they do, with the **same rule at both scopes** — scope only chooses the root (`process.cwd()` vs `homedir()`). No agent needs a directory of its own, and in particular there is no `.codev/skills` or `.opencode/skills` link to write.

`resolveTargets` therefore extracts **once** into whichever directory covers the most selected agents and links the other only if some selected agent can't reach it. When Codex isn't selected, `.claude/skills` alone serves everyone and only one directory is created. Adding Codex is what forces the second into existence. A hub skill can run to thousands of files (one is ~11k), so fanning out a copy per agent is not free.

**Links are relative symlinks.** This repo's own skill is wired exactly that way — `.claude/skills/vercel-react-best-practices -> ../../.agents/skills/vercel-react-best-practices`, committed as git mode 120000 — and relative is what survives `git clone`; an absolute link breaks in every other checkout. `linkOrCopy` degrades in order: relative symlink → Windows **junction** (absolute, but needs neither Developer Mode nor admin, unlike a Windows symlink) → recursive copy. The mode is reported verbatim in `InstallResult.placements`, so a fallback copy is never described as a link.

**Claude Code follows a symlinked skill directory only from v2.1.203.** Below that the link reads as a file with no SKILL.md and the skill is simply invisible, so `claudeFollowsSymlinks()` probes `claude --version` and forces a copy for that one link. Tests must stub `npm.execAsync` or they depend on whatever Claude Code the machine happens to have.

**The duplicate-name warning is expected, not a bug.** When both directories exist, CoDev Code and OpenCode scan both, reach the same skill twice, and log `duplicate skill name` (`index.ts:125`); the last scan wins and the skill resolves to a single entry. It is log-only — warnings are not published as session events, unlike the parse errors just above them — and it already happens for any user whose skill sits in both directories, independent of CoDev. Users have `CODEV_DISABLE_CLAUDE_CODE_SKILLS` / `CODEV_DISABLE_EXTERNAL_SKILLS` (`packages/opencode/src/effect/runtime-flags.ts`); **CoDev must not set those on their behalf.**

**Verifying against CoDev Code: query `/skill`, not `/api/skill`.** They are two different services. `/api/skill` is v2 (`packages/core/src/skill.ts` + `config/plugin/skill.ts`), which registers only `<configdir>/skill{,s}` plus `skills.paths` and so reports **nothing** from `.claude`/`.agents` — querying it will look exactly like a broken install. `/skill` is v1, the service `session/system.ts` uses to build the actual prompt. If CoDev Code ever migrates the prompt to v2, `.agents/skills` will need a `skills.paths` entry and this design changes.

CoDev Code is the flagship: `ALWAYS_AGENT` is in every target set, the picker renders it locked, and `--agent` folds it in whether or not it was named.

## CodeGraph integration

`src/lib/codegraph.ts` integrates the external [CodeGraph](https://www.npmjs.com/package/@colbymchenry/codegraph) tool (a CLI + MCP server). Two surfaces:
Expand Down
95 changes: 86 additions & 9 deletions src/SkillPullApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,39 @@ import { Step } from "@/components/Step.js";
import { useCanType } from "@/components/useCanType.js";
import { stripControlChars } from "@/lib/sanitize.js";
import {
AGENT_LABELS,
ALWAYS_AGENT,
SKILL_AGENTS,
type SkillAgent,
} from "@/lib/skill-dirs.js";
import {
defaultAgents,
formatInstallResult,
type InstallLocation,
type InstallResult,
installResolvedSkill,
skillsDirFor,
} from "@/lib/skill-install.js";
import { getSkillMeta, type SkillMeta } from "@/lib/skillhub.js";

interface SkillPullAppProps {
target: string;
force: boolean;
json: boolean;
// Agent set from --agent/--all-agents. Absent ⇒ prompt for it, pre-checked
// with defaultAgents().
agents?: SkillAgent[];
// Reports success/failure so the caller can set the process exit code, then
// the app exits on its own. Optional so tests can omit it.
onDone?: (ok: boolean) => void;
}

type Phase = "resolving" | "select" | "installing" | "done" | "error";
type Phase =
| "resolving"
| "select"
| "agents"
| "installing"
| "done"
| "error";

const LOCATIONS: { key: InstallLocation; label: string }[] = [
{ key: "current", label: "Current directory" },
Expand All @@ -35,6 +50,7 @@ export function SkillPullApp({
target,
force,
json,
agents,
onDone,
}: SkillPullAppProps) {
const { exit } = useApp();
Expand All @@ -44,6 +60,13 @@ export function SkillPullApp({
const [index, setIndex] = useState(0);
const [result, setResult] = useState<InstallResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [scope, setScope] = useState<InstallLocation>("current");
const [agentIndex, setAgentIndex] = useState(0);
// Pre-check: whatever CoDev has configured, plus the always-on flagship.
// Computed once — detectCodevTools() hits the filesystem.
const [picked, setPicked] = useState<Set<SkillAgent>>(
() => new Set(agents ?? defaultAgents()),
);

// Signal the outcome, then unmount. exit() takes no error — the exit code is
// carried by onDone — so waitUntilExit resolves cleanly either way.
Expand Down Expand Up @@ -78,12 +101,12 @@ export function SkillPullApp({
}, [target, finish]);

const start = useCallback(
async (location: InstallLocation) => {
async (location: InstallLocation, chosen: readonly SkillAgent[]) => {
if (!meta) return;
setPhase("installing");
try {
const r = await installResolvedSkill(meta, {
rootDir: skillsDirFor(location),
target: { kind: "agents", agents: [...chosen], scope: location },
force,
});
setResult(r);
Expand All @@ -99,14 +122,14 @@ export function SkillPullApp({
);

// The dispatcher already routes a keyboard-less terminal to the plain runner,
// so reaching "select" without one means Ink's stdin isn't the process's own.
// so reaching a prompt without one means Ink's stdin isn't the process's own.
// Say so and exit rather than mounting a picker that can never be answered:
// unlike the ungated case this is a silent hang, not a throw.
useEffect(() => {
if (phase !== "select" || canType) return;
if ((phase !== "select" && phase !== "agents") || canType) return;
setError(
"This terminal cannot supply keystrokes, so the location prompt cannot be shown.\n" +
"Pass --here, --global, or --dir <path> to choose a location without it.",
"This terminal cannot supply keystrokes, so the install prompts cannot be shown.\n" +
"Pass --here, --global, or --dir <path> to choose a location without them.",
);
setPhase("error");
finish(false);
Expand All @@ -120,12 +143,47 @@ export function SkillPullApp({
} else if (key.downArrow) {
setIndex((i) => (i + 1) % LOCATIONS.length);
} else if (key.return) {
void start(LOCATIONS[index]?.key ?? "current");
const location = LOCATIONS[index]?.key ?? "current";
setScope(location);
// An explicit --agent/--all-agents already answered the second
// question; don't ask it again.
if (agents) {
void start(location, agents);
return;
}
setPhase("agents");
}
},
{ isActive: canType && phase === "select" },
);

useInput(
(input, key) => {
if (phase !== "agents") return;
if (key.upArrow) {
setAgentIndex((i) => (i === 0 ? SKILL_AGENTS.length - 1 : i - 1));
} else if (key.downArrow) {
setAgentIndex((i) => (i + 1) % SKILL_AGENTS.length);
} else if (input === " ") {
const agent = SKILL_AGENTS[agentIndex];
// CoDev Code is the flagship and is never opted out of.
if (!agent || agent === ALWAYS_AGENT) return;
setPicked((prev) => {
const next = new Set(prev);
if (next.has(agent)) next.delete(agent);
else next.add(agent);
return next;
});
} else if (key.return) {
void start(
scope,
SKILL_AGENTS.filter((a) => picked.has(a)),
);
}
},
{ isActive: canType && phase === "agents" },
);

// Sanitize the hub-sourced name before rendering it to the terminal.
const skillName = meta ? stripControlChars(meta.name) : null;
const title = skillName ? `Install ${skillName} skill` : "Install skill";
Expand Down Expand Up @@ -153,6 +211,25 @@ export function SkillPullApp({
))}
</Box>
)}
{phase === "agents" && (
<Box flexDirection="column">
<Text dimColor>For which agents?</Text>
{SKILL_AGENTS.map((agent, i) => {
const locked = agent === ALWAYS_AGENT;
const checked = locked || picked.has(agent);
return (
<Text
key={agent}
color={i === agentIndex ? "cyan" : undefined}
dimColor={locked}
>
{`${i === agentIndex ? "❯ " : " "}[${checked ? "✓" : " "}] ${AGENT_LABELS[agent]}`}
</Text>
);
})}
<Text dimColor>space toggles · enter confirms</Text>
</Box>
)}
{phase === "installing" && (
<Box>
<Text color="cyan">
Expand Down
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ switch (command) {
target={parsed.target}
force={parsed.force}
json={parsed.json}
agents={parsed.agents}
onDone={(v) => {
ok = v;
}}
Expand Down
7 changes: 4 additions & 3 deletions src/lib/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@ Skill hub:
skill search <query> Search the public skill hub
(--json for machine-readable output,
--limit <n> to cap results, default 20)
skill pull <name> Download and install a skill by name or id
(prompts for location; --here or --global to skip the
prompt, --dir <path> for an exact path, --force to
skill pull <name> Download and install a skill for your agents
(prompts for location and agents; --here or --global to
set the scope, --agent <list> or --all-agents to set the
agents, --dir <path> for an exact path, --force to
overwrite; --json for machine-readable output)
skill push <path> Publish a skill (a directory with SKILL.md, or a .zip)
(previews and confirms before upload; --draft-only to stop
Expand Down
Loading
Loading