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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ The install flow's "Skip configuration" auth choice routes Configure through `ba

The `deleted` vs `kept-live` split turns on `isCodevAuthored(kind)` in `configure.ts`, and that gate carries the whole safety argument. Restore **consumes** the backup (`renameSync`), so "no backup + live file" is ambiguous: it can mean CoDev wrote the file from scratch, but equally that this is a *second* restore and the live file is the pristine original the first run just reinstated, or that the user hand-wrote a config for a tool CoDev never configured (both `remove` and the bare `restore` sweep visit every tool). Only the first case is ours to delete. `isCodevAuthored` reuses the same per-kind marker detectors `codevhub model` uses (`isCodevClaudeConfig`, `isCodevCodexConfig`, `isCodevOpenCodeConfig`, `isCodevContinueConfig`); each returns `false` for a missing or unparseable file, so anything we can't attribute is kept. The two auth files have no marker key of their own: `claude-json` is matched by whole-file *shape* (`isCodevClaudeJsonStub` — exactly `{hasCompletedOnboarding: true}`, since a real `~/.claude.json` accumulates projects/history/mcpServers), and `claude-credentials` always returns `true` because CoDev never writes that file, only removes it, so a live one can only postdate CoDev. Deliberately no cross-kind inference (e.g. reading settings.json to decide the credentials' fate): `restoreTool` restores `claude-settings` first, which erases that marker, so the answer would depend on iteration order.

`restoreKind`/`restoreTool`/`runRestore`/`runRestoreAll`/`runRemove` all take a trailing `force = false`. `codevhub restore [agent] --force` and `codevhub remove --force` set it, and it **bypasses the authorship gate only**: a backup-less live file is deleted whoever wrote it, so `kept-live` never happens. It deliberately does *not* override the backup branch — a `*.backup` still wins and is still restored, because that file is the user's pre-CoDev original and reinstating it is the whole point of the command. The flag is **intentionally absent from `help.ts`, the README, and all UI copy**; it's an escape hatch, discoverable only from source, and nothing invokes it on the user's behalf. Keep it that way, and keep it long-form only (no `-f` alias) so the reflex `-f` from `upload`/`login` can't unconditionally delete configs by accident.
`restoreKind`/`restoreTool`/`runRestore`/`runRestoreAll`/`runRemove` all take a trailing `force = false`. `codevhub restore [agent] --force` and `codevhub remove --force` set it, and it **bypasses the authorship gate only**: a backup-less live file is deleted whoever wrote it, so `kept-live` never happens. It deliberately does *not* override the backup branch — a `*.backup` still wins and is still restored, because that file is the user's pre-CoDev original and reinstating it is the whole point of the command. The flag is **intentionally absent from `help.ts`, the README, and all UI copy**; it's an escape hatch, discoverable only from source, and nothing invokes it on the user's behalf. Keep it that way, and keep it long-form only (no `-f` alias) so the reflex `-f` from `upload`/`login` can't unconditionally delete configs by accident. That last rule is **general, not a restore detail**: `-f` is reserved for flags that cost nothing when fired by reflex, so any flag that destroys a user's files stays long-form. `codevhub skill pull --force` follows it for the same reason — it `rm -rf`s the skill directory, which may hold local edits, while `-f` on `login`/`upload`/`doctor` only forces a fresh sign-in. What enforces it there is `parsePullArgs`'s `PULL_FLAGS` allowlist: an unrecognized `-`-prefixed token is an error, so `-f` is rejected loudly rather than silently ignored.

Reporting stays honest under force: `restoreKind` evaluates `isCodevAuthored` even when forcing and sets `RestoreResult.forced = !authored`, so a file taken *despite* not being CoDev's is reported as forced rather than under the default message (which asserts "CoDev wrote it" and would be a false claim about the user's own file). `remove`'s aggregate detail counts them separately (`deleted 2 files (no backup, 1 forced)`), and the NDJSON log carries `forced` on every `restore.kind` document.

Expand Down
18 changes: 17 additions & 1 deletion src/SkillPullApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { Banner } from "@/components/Banner.js";
import { Frame } from "@/components/Frame.js";
import { Step } from "@/components/Step.js";
import { useCanType } from "@/components/useCanType.js";
import { stripControlChars } from "@/lib/sanitize.js";
import {
formatInstallResult,
Expand Down Expand Up @@ -37,6 +38,7 @@ export function SkillPullApp({
onDone,
}: SkillPullAppProps) {
const { exit } = useApp();
const canType = useCanType();
const [phase, setPhase] = useState<Phase>("resolving");
const [meta, setMeta] = useState<SkillMeta | null>(null);
const [index, setIndex] = useState(0);
Expand Down Expand Up @@ -96,6 +98,20 @@ export function SkillPullApp({
[meta, force, finish],
);

// 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.
// 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;
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.",
);
setPhase("error");
finish(false);
}, [phase, canType, finish]);

useInput(
(_input, key) => {
if (phase !== "select") return;
Expand All @@ -107,7 +123,7 @@ export function SkillPullApp({
void start(LOCATIONS[index]?.key ?? "current");
}
},
{ isActive: phase === "select" },
{ isActive: canType && phase === "select" },
);

// Sanitize the hub-sourced name before rendering it to the terminal.
Expand Down
19 changes: 18 additions & 1 deletion src/SkillPushApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Banner } from "@/components/Banner.js";
import { Frame } from "@/components/Frame.js";
import { Login, loginTitle } from "@/components/Login.js";
import { Step } from "@/components/Step.js";
import { useCanType } from "@/components/useCanType.js";
import { type AuthData, loadAuth } from "@/lib/auth.js";
import {
formatPublishResult,
Expand Down Expand Up @@ -85,6 +86,7 @@ export function SkillPushApp({
onDone,
}: SkillPushAppProps) {
const { exit } = useApp();
const canType = useCanType();
const [phase, setPhase] = useState<Phase>("preparing");
const [archive, setArchive] = useState<PublishArchive | null>(null);
const [result, setResult] = useState<PublishResult | null>(null);
Expand Down Expand Up @@ -176,6 +178,21 @@ export function SkillPushApp({
[start],
);

// The dispatcher already routes a keyboard-less terminal to the plain runner,
// so reaching "confirm" without one means Ink's stdin isn't the process's own.
// Bail rather than mount a confirmation nobody can answer — and never assume
// consent from the silence: this step is the last thing standing between the
// user and an upload.
useEffect(() => {
if (phase !== "confirm" || canType) return;
setError(
"This terminal cannot supply keystrokes, so the confirmation prompt cannot be shown.\n" +
"Re-run with --json to publish without confirming.",
);
setPhase("error");
finish(false);
}, [phase, canType, finish]);

useInput(
(input, key) => {
if (phase !== "confirm") return;
Expand All @@ -188,7 +205,7 @@ export function SkillPushApp({
finish(false);
}
},
{ isActive: phase === "confirm" },
{ isActive: canType && phase === "confirm" },
);

// After confirmation: if a credential is already available, record the
Expand Down
41 changes: 31 additions & 10 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,18 @@ import {
type ShimAgent,
uninstallShims,
} from "@/lib/shims.js";
import { parsePullArgs, runSkillInstall } from "@/lib/skill-install.js";
import {
PULL_USAGE,
parsePullArgs,
runSkillInstall,
} from "@/lib/skill-install.js";
import { parsePublishArgs, runSkillPublish } from "@/lib/skill-publish.js";
import { runSkillSearch } from "@/lib/skill-search.js";
import { interactiveTerminalBlocker, stdinKind } from "@/lib/tty.js";
import {
interactiveTerminalBlocker,
rawModeSupported,
stdinKind,
} from "@/lib/tty.js";
import { runUploadDaemon, spawnUploadDaemon } from "@/lib/upload.js";
import { ModelApp } from "@/ModelApp.js";
import { RemoveApp } from "@/RemoveApp.js";
Expand Down Expand Up @@ -94,6 +102,19 @@ function flagValue(argv: string[], name: string): string | undefined {
return undefined;
}

// Whether a skill subcommand should mount its Ink prompt rather than fall back
// to its non-interactive runner. Two independent conditions:
//
// - the keyboard, asked via `rawModeSupported()` — Ink gates raw mode on
// `stdin.isTTY` alone, and lib/tty.ts exists so that gate is stated in one
// place; re-deriving it here is how the two silently drift apart.
// - stdout being a terminal, which is not about the keyboard at all: it keeps
// `codevhub skill pull … | tee log` on the plain runner instead of piping a
// rendered Ink frame full of ANSI into a file.
function skillPromptUsable(): boolean {
return rawModeSupported() && Boolean(process.stdout.isTTY);
}

// Refuse, with an explanation, when a command is about to mount a keyboard
// prompt in a terminal that cannot supply one. Ink gates raw mode on
// `stdin.isTTY` alone, so in Git Bash (an MSYS pipe, not a Windows console)
Expand Down Expand Up @@ -382,7 +403,7 @@ switch (command) {
}
// Interactive TTY (and not --json): preview + confirm before uploading
// (Ink). Otherwise (piped/CI, or --json) go the plain runner.
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
const interactive = skillPromptUsable();
if (interactive && !parsed.json) {
let ok = true;
const { waitUntilExit } = render(
Expand Down Expand Up @@ -412,15 +433,15 @@ switch (command) {
process.exit(1);
}
if (!parsed.target) {
console.error(
"Usage: codevhub skill pull <name|id> [--dir <path>] [--force] [--json]",
);
console.error(PULL_USAGE);
process.exit(1);
}
// Interactive + no explicit --dir: prompt for the location (Ink).
// Otherwise (--dir given, or piped/CI) go the plain non-interactive path.
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
if (parsed.dir === undefined && interactive) {
// Interactive + no explicit location: prompt for one (Ink). Otherwise
// (--here/--global/--dir given, or piped/CI) go the plain
// non-interactive path.
const interactive = skillPromptUsable();
const located = parsed.dir !== undefined || parsed.location !== undefined;
if (!located && interactive) {
let ok = true;
const { waitUntilExit } = render(
<SkillPullApp
Expand Down
5 changes: 3 additions & 2 deletions src/lib/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ 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; --dir <path> to set it explicitly,
--force to overwrite; --json for machine-readable output)
(prompts for location; --here or --global to skip the
prompt, --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
at DRAFT, --auto-approve for admins, --json for output)
Expand Down
105 changes: 80 additions & 25 deletions src/lib/skill-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ function safeSkillDir(rootDir: string, name: string): string {

export type InstallLocation = "current" | "global";

// One usage string for both entry points (dispatcher and non-interactive
// runner), so the flag list can't drift between them.
export const PULL_USAGE =
"Usage: codevhub skill pull <name|id> [--here|--global|--dir <path>] [--force] [--json]";

// Root dir for each prompt choice. "current" is cwd-relative (created if
// missing on install); "global" is the home skills dir. Claude Agent Skills
// live under `.claude/skills` in both cases.
Expand All @@ -43,39 +48,83 @@ export function skillsDirFor(location: InstallLocation): string {

export interface ParsedPull {
target?: string;
// Exact install root, verbatim from --dir: the skill lands in <dir>/<name>,
// with no `.claude/skills` segment added. Mutually exclusive with `location`.
dir?: string;
// The picker's choice expressed as a flag (--here / --global), resolved
// through skillsDirFor. Mutually exclusive with `dir`.
location?: InstallLocation;
force: boolean;
json: boolean;
error?: string;
}

// Every flag `pull` accepts. Anything else starting with "-" is a typo, and is
// rejected rather than ignored — a silently dropped `--forse` looks like a
// successful run that just didn't do what was asked.
//
// Deliberately no `-f` alias, for the same reason `restore --force` has none:
// `-f` elsewhere in this CLI (`login`, `upload`, `doctor`) forces a fresh login,
// which costs nothing, while here the identical keystroke is an `rm -rf` of a
// skill directory that may hold local edits. The reflex must not reach it.
const PULL_FLAGS = new Set([
"--dir",
"--force",
"--json",
"--here",
"--global",
]);

// Parse `pull` args: first non-flag token is the target (name|id); flags are
// --dir <path>, --force/-f, --json. Shared by the interactive (index → app) and
// non-interactive (runSkillInstall) paths so parsing lives in one place.
// --here/--global, --dir <path>, --force, --json. Shared by the interactive
// (index → app) and non-interactive (runSkillInstall) paths so parsing lives in
// one place.
export function parsePullArgs(args: string[]): ParsedPull {
const force = args.includes("--force") || args.includes("-f");
const force = args.includes("--force");
const json = args.includes("--json");

let dir: string | undefined;
const dirIdx = args.indexOf("--dir");
if (dirIdx !== -1) {
const value = args[dirIdx + 1];
if (!value || value.startsWith("-")) {
return { force, json, error: "Missing value for --dir." };
}
dir = value;
const here = args.includes("--here");
const global = args.includes("--global");
if (here && global) {
return { force, json, error: "Pass either --here or --global, not both." };
}
const location: InstallLocation | undefined = here
? "current"
: global
? "global"
: undefined;

let dir: string | undefined;
const positionals: string[] = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === "--dir") {
i++; // skip its value
const arg = args[i] as string;
if (arg === "--dir") {
// Consume the value here so it is never mistaken for a flag or a target,
// whatever it contains.
const value = args[++i];
if (!value || value.startsWith("-")) {
return { force, json, error: "Missing value for --dir." };
}
dir = value;
continue;
}
if (args[i]?.startsWith("-")) continue;
positionals.push(args[i] as string);
if (arg.startsWith("-")) {
if (!PULL_FLAGS.has(arg)) {
return { force, json, error: `Unknown flag: ${arg}` };
}
continue;
}
positionals.push(arg);
}

if (dir !== undefined && location !== undefined) {
return {
force,
json,
error: "Pass either --dir or --here/--global, not both.",
};
}
return { target: positionals[0], dir, force, json };
return { target: positionals[0], dir, location, force, json };
}

export interface InstallResult {
Expand Down Expand Up @@ -155,31 +204,37 @@ export function formatInstallResult(r: InstallResult, json: boolean): string {
return `Installed ${name}${versionSuffix} -> ${r.dir}`;
}

// Non-interactive path (`--dir` given, or piped/CI). The interactive location
// prompt lives in SkillPullApp; here a location MUST be explicit, so a missing
// --dir is an error rather than a silent default. Returns the exit code.
// Non-interactive path (a location flag given, or piped/CI). The interactive
// prompt lives in SkillPullApp; here a location MUST be explicit, so no flag at
// all is an error rather than a silent default. Returns the exit code.
export async function runSkillInstall(args: string[]): Promise<number> {
const parsed = parsePullArgs(args);
if (parsed.error) {
console.error(parsed.error);
return 1;
}
if (!parsed.target) {
console.error(
"Usage: codevhub skill pull <name|id> [--dir <path>] [--force] [--json]",
);
console.error(PULL_USAGE);
return 1;
}
if (!parsed.dir) {
// --here/--global reproduce the picker's two choices (and so append
// `.claude/skills`); --dir is the escape hatch and is used verbatim.
const rootDir =
parsed.dir !== undefined
? resolve(parsed.dir)
: parsed.location !== undefined
? skillsDirFor(parsed.location)
: null;
if (rootDir === null) {
console.error(
"Not a terminal — pass --dir <path> to choose an install location.",
"Not a terminal — pass --here, --global, or --dir <path> to choose an install location.",
);
return 1;
}

try {
const result = await installSkill(parsed.target, {
rootDir: resolve(parsed.dir),
rootDir,
force: parsed.force,
});
console.log(formatInstallResult(result, parsed.json));
Expand Down
31 changes: 31 additions & 0 deletions tests/SkillPullApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,37 @@ describe("SkillPullApp", () => {
expect(spy.mock.calls[0]?.[1]).toMatchObject({ force: true });
});

// A terminal with no raw mode (Git Bash on Windows — see lib/tty.ts). The
// dispatcher normally routes those to the plain runner, so this covers the
// case where Ink's stdin isn't the process's own. Unlike an ungated useInput
// (which throws), an unanswerable picker would just hang forever.
// ink-testing-library's stdin reports isTTY true and takes no options, so the
// flag is flipped and the tree re-rendered — Ink recomputes
// `isRawModeSupported` every render.
test("without raw mode: explains the missing keyboard instead of prompting", async () => {
mockResolve();
const spy = vi.spyOn(install, "installResolvedSkill");
const onDone = vi.fn();
const node = (
<SkillPullApp target={ID} force={false} json={false} onDone={onDone} />
);

const instance = render(node);
instance.stdin.isTTY = false;
instance.rerender(node);

await waitFor(() =>
frameText(instance.lastFrame).includes("cannot supply keystrokes"),
);
const frame = frameText(instance.lastFrame);
expect(frame).toContain("--here, --global, or --dir");
// Never falls back to a location the user didn't choose.
expect(frame).not.toContain("❯ ");
expect(spy).not.toHaveBeenCalled();
await waitFor(() => onDone.mock.calls.length > 0);
expect(onDone).toHaveBeenCalledWith(false);
});

test("shows an error when the skill can't be resolved", async () => {
vi.spyOn(skillhub, "getSkillMeta").mockRejectedValue(
new Error('Skill "bad-id" not found or not public.'),
Expand Down
Loading
Loading