Skip to content

feat: give skill pull explicit location flags and stricter parsing - #226

Merged
quickbeard merged 2 commits into
mainfrom
skill-pull-location-flags
Jul 30, 2026
Merged

feat: give skill pull explicit location flags and stricter parsing#226
quickbeard merged 2 commits into
mainfrom
skill-pull-location-flags

Conversation

@quickbeard

@quickbeard quickbeard commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Why

skill pull's two entry points disagreed about layout. The interactive picker installs to <root>/.claude/skills/<name>; --dir X installs to X/<name> verbatim. So the non-interactive path — the one --dir exists for — had no way to reach the layout agents actually read, and skill pull foo --dir . quietly landed in ./foo with neither help.ts nor the usage string mentioning it.

What changed

Location flags. New --here / --global route through skillsDirFor and reproduce the picker's two choices:

skill pull foo --here      -> ./.claude/skills/foo
skill pull foo --global    -> ~/.claude/skills/foo
skill pull foo --dir /srv  -> /srv/foo          (unchanged)

--dir keeps its exact-path meaning as the escape hatch. Combining it with either, or --here with --global, is a parse error. The usage string moved to an exported PULL_USAGE since the dispatcher and the runner each had their own copy.

Stricter parsing. parsePullArgs is now a single pass over an allowlist. An unrecognized --prefixed token returns Unknown flag: --forse instead of being dropped — silently ignoring it looked like a successful run that just didn't do what was asked. The --dir value is consumed by that same pass, so it can't be re-read as a flag or as the target; the old code scanned twice and could disagree with itself about which token was the value.

No -f alias, for the reason restore --force has none: -f on login/upload/doctor forces a fresh login and costs nothing, while here the identical keystroke is an rm -rf of a skill directory that may hold local edits. --force is unchanged. AGENTS.md now states this as a general rule about destructive flags rather than a restore detail, and names both instances.

Raw-mode gating. The dispatcher's two hand-rolled Boolean(process.stdin.isTTY && process.stdout.isTTY) checks become skillPromptUsable(). It keeps both conditions but sources them separately, because they answer different questions:

  • the keyboard half is rawModeSupported()lib/tty.ts states Ink's gate once so the two can't drift;
  • stdout.isTTY stays and is deliberately not folded into tty.ts. It's a scripting heuristic, not a raw-mode one — dropping it would send skill pull … | tee log into the Ink app and pipe a rendered ANSI frame into the file.

SkillPullApp and SkillPushApp now gate useInput on useCanType() and bail with an explanation naming the escape hatch (--here/--global/--dir, --json) if their input phase is reached without a keyboard. Note the failure mode differs from the one tty.ts was written for: with useInput gated but no effect there's no throw, just a prompt that hangs forever. For push it refuses rather than proceeds — that confirm is the last thing between the user and an upload, so silence must not be read as consent.

Testing

pnpm fix, pnpm typecheck, pnpm test (1262 passed), pnpm build + smoke-run all pass.

New coverage: both layouts end-to-end; --global asserted through the pure helpers so no test writes to a real $HOME; unknown-flag rejection; both conflict cases; --dir value consumption; -f rejected with the pre-existing directory verified intact, not just a non-zero exit; and a no-raw-mode render of each app using the established flip-isTTY-and-rerender pattern from Login.test.tsx.

One existing assertion (/pass --dir/i) was updated to the new message rather than left matching a string the code no longer emits.

Also verified against the built bundle under piped stdin — skill pull, skill push and the no-location case all degrade to their runners with a plain sentence, no React stack and no hang.

Not included

  • Version pinning (skill pull foo@1.2). GET /api/v1/skills/<target> accepts a name or id only, so this needs server work first; today the @1.2 is sent as part of the name and comes back as Skill "foo@1.2" not found or not public.

🤖 Generated with Claude Code

quickbeard and others added 2 commits July 30, 2026 22:28
`--dir` is an exact path (`<dir>/<name>`), while the interactive picker
appends `.claude/skills`. The non-interactive path — the one `--dir` exists
for — therefore had no way to reach the layout agents actually read, and
`skill pull foo --dir .` landed in `./foo` with nothing saying so.

Add `--here` / `--global`, which route through `skillsDirFor` and reproduce
the picker's two choices. `--dir` keeps its exact-path meaning as the escape
hatch; combining it with either, or `--here` with `--global`, is an error.
The usage string moves to an exported `PULL_USAGE` — the dispatcher and the
runner each had their own copy.

`parsePullArgs` becomes a single pass with an allowlist: an unrecognized
`-`-prefixed token is now `Unknown flag: --forse` rather than silently
dropped, which used to look like a successful run that just didn't do what
was asked. The `--dir` value is consumed by that same pass, so it can no
longer be re-read as a flag or as the target.

Drop the `-f` alias for `--force`, for the reason `restore --force` has
none: `-f` on `login`/`upload`/`doctor` forces a fresh login and costs
nothing, while here the identical keystroke is an `rm -rf` of a skill
directory that may hold local edits.

Finally, both skill apps now ask about the keyboard before prompting.
The dispatcher's hand-rolled TTY checks are replaced by `skillPromptUsable()`,
whose keyboard half is `rawModeSupported()` (lib/tty.ts states Ink's gate
once, so the two can't drift) and whose `stdout.isTTY` half stays as the
separate scripting heuristic it always was — dropping it would pipe a
rendered Ink frame into `… | tee log`. `SkillPullApp`/`SkillPushApp` gate
`useInput` on `useCanType()` and bail with an explanation if their input
phase is ever reached without a keyboard: unlike an ungated `useInput` that
would be a silent hang, and for push, silence must never be read as consent
to upload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…detail

The rationale for `restore --force` having no `-f` alias applies verbatim to
`skill pull --force`, which rm -rf's a skill directory that may hold local
edits. State it once as a rule about destructive flags, name the second
instance, and point at the allowlist that enforces it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard
quickbeard merged commit 6bbfe14 into main Jul 30, 2026
4 checks passed
@quickbeard
quickbeard deleted the skill-pull-location-flags branch July 30, 2026 15:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant