mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 03:31:36 +00:00
Add install-from-prompt to the Skills tab
Skill marketplaces (SkillsMP and friends) publish an install prompt meant to be pasted into an interactive claude, which fetches the skill's files and places them under a skills directory. Handler has no interactive claude and its skills are DB rows, so the Skills tab gains an "Install from a marketplace prompt" card wired to a new skill_install command: the worker runs the pasted prompt through a one-off headless claude in a throwaway staging directory (sandboxed by a generated settings.json allowing fetch/clone tooling with acceptEdits), then imports whatever <skill>/SKILL.md landed as managed rows — reinstalling a skill updates it in place. Headless means nobody can answer questions mid-install, so the wrapper prompt front-loads the answers a human would give: install into the staging dir, always user scope (Handler distributes skills to workers itself), pick the instructions' defaults, never stop to ask, and end with a report of the choices made — surfaced in the command result for after-the-fact review, with the imported skill editable/disableable in the UI. Multi-file skills survive the import: a new claude_skill_files table (migration 0011, alongside the command-type constraint change) captures auxiliary files (references/, scripts/, ...), the launch-time sync rebuilds each managed skill dir from them, and skill cards list what a skill ships with. The one-off run's timeout defaults under worker_stale_after so a slow install can't get the worker's live runs falsely reaped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f42XmjtVsc3zQ9Dhn6DqZ
This commit is contained in:
@@ -43,6 +43,51 @@ function parseLines(text: string): string[] {
|
||||
|
||||
const emptySkill = { name: "", description: "", content: "", enabled: true };
|
||||
|
||||
/* Install-from-prompt: paste the "install prompt" a marketplace page (SkillsMP etc.)
|
||||
* shows, and the worker runs it through a one-off headless claude, importing whatever
|
||||
* it fetches as managed skills. Headless = nobody to answer questions, so the run makes
|
||||
* the choices a human would be asked (scope, options) itself — always user scope,
|
||||
* sensible defaults — and reports them; the import lands below for review/editing. */
|
||||
function InstallCard() {
|
||||
const s = useDashboard();
|
||||
const [prompt, setPrompt] = useState("");
|
||||
|
||||
const run = async () => {
|
||||
const ok = await s.installClaudeSkill(prompt.trim());
|
||||
if (ok) setPrompt("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="card-head" style={{ marginBottom: 14 }}>
|
||||
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
|
||||
Install from a marketplace prompt
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
label="Paste the skill's install prompt (from SkillsMP or any marketplace page)"
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
rows={5}
|
||||
placeholder={"Install the pdf-tools skill from https://…\n(the whole prompt the marketplace tells you to paste into Claude)"}
|
||||
/>
|
||||
<div className="faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
|
||||
Runs headlessly on the worker — nobody can answer questions mid-install, so when
|
||||
the instructions offer choices (user vs repo scope, optional variants) Claude
|
||||
picks the defaults itself: skills here are always <b>user scope</b> (Handler
|
||||
syncs them to every worker), and recommended options win. What it chose is
|
||||
reported in the result — review the imported skill below and edit or disable it
|
||||
if a choice was wrong.
|
||||
</div>
|
||||
<div className="hstack mt14">
|
||||
<Button variant="primary" disabled={s.cmd.busy || !prompt.trim()} onClick={run}>
|
||||
{s.cmd.busy ? "Installing…" : "Run install"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillsPanel() {
|
||||
const s = useDashboard();
|
||||
const [form, setForm] = useState(emptySkill);
|
||||
@@ -77,8 +122,10 @@ function SkillsPanel() {
|
||||
<div className="faint" style={{ fontSize: "var(--text-sm)", marginBottom: 14 }}>
|
||||
Custom Claude Code skills, synced to every worker's{" "}
|
||||
<span className="mono">~/.claude/skills</span> at each launch. The description is
|
||||
what makes Claude pick the skill up — say when to use it.
|
||||
what makes Claude pick the skill up — say when to use it. Install one from a
|
||||
marketplace prompt, or author one by hand below.
|
||||
</div>
|
||||
<InstallCard />
|
||||
<Card>
|
||||
<div className="card-head" style={{ marginBottom: 14 }}>
|
||||
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
|
||||
@@ -150,6 +197,11 @@ function SkillsPanel() {
|
||||
{sk.description}
|
||||
</div>
|
||||
)}
|
||||
{sk.files.length > 0 && (
|
||||
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
|
||||
ships with: {sk.files.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -137,6 +137,8 @@ interface StoreValue {
|
||||
createClaudeSkill: (b: SkillBody) => Promise<boolean>;
|
||||
updateClaudeSkill: (id: number, b: Partial<SkillBody>) => Promise<boolean>;
|
||||
deleteClaudeSkill: (id: number) => Promise<void>;
|
||||
/* Run a pasted marketplace install prompt on the worker and import the result. */
|
||||
installClaudeSkill: (prompt: string) => Promise<boolean>;
|
||||
createClaudeConnector: (b: ConnectorBody) => Promise<boolean>;
|
||||
updateClaudeConnector: (id: number, b: Partial<ConnectorBody>) => Promise<boolean>;
|
||||
deleteClaudeConnector: (id: number) => Promise<void>;
|
||||
@@ -1061,6 +1063,55 @@ export function DashboardProvider({
|
||||
[claudeWrite],
|
||||
);
|
||||
|
||||
const installClaudeSkill = useCallback(
|
||||
async (prompt: string): Promise<boolean> => {
|
||||
// A worker-run command (the API has no claude), tracked like login/spawn. The
|
||||
// worker-side run is budgeted at ~4 minutes; poll a little past that.
|
||||
setCmd({
|
||||
text: "skill install: running the marketplace prompt through headless claude on the worker…",
|
||||
error: false,
|
||||
busy: true,
|
||||
});
|
||||
try {
|
||||
const command = await clientRef.current.api<Command>("/claude/skills/install", {
|
||||
method: "POST",
|
||||
body: { prompt },
|
||||
});
|
||||
const final = await clientRef.current.trackCommand(command.id, { attempts: 600 });
|
||||
if (!final) {
|
||||
setCmd({
|
||||
text: "skill install: still running (see Activity). Is the control worker running?",
|
||||
error: false,
|
||||
busy: false,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (final.status !== "done") {
|
||||
setCmd({
|
||||
text: `skill install failed — ${final.error ?? "unknown error"}`,
|
||||
error: true,
|
||||
busy: false,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const skills = (final.result?.skills ?? []) as { name: string; action: string }[];
|
||||
const names = skills.map((s) => `${s.name} (${s.action})`).join(", ");
|
||||
setCmd({
|
||||
text: `skill install done: ${names || "no skills"} — review the import below; Claude's report of the choices it made is in Activity.`,
|
||||
error: false,
|
||||
busy: false,
|
||||
});
|
||||
await loadClaude();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return false;
|
||||
setCmd({ text: `skill install failed: ${(e as Error).message}`, error: true, busy: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[loadClaude],
|
||||
);
|
||||
|
||||
const createClaudeConnector = useCallback(
|
||||
(b: ConnectorBody) =>
|
||||
claudeWrite(
|
||||
@@ -1210,6 +1261,7 @@ export function DashboardProvider({
|
||||
createClaudeSkill,
|
||||
updateClaudeSkill,
|
||||
deleteClaudeSkill,
|
||||
installClaudeSkill,
|
||||
createClaudeConnector,
|
||||
updateClaudeConnector,
|
||||
deleteClaudeConnector,
|
||||
|
||||
@@ -148,6 +148,9 @@ export interface ClaudeSkill {
|
||||
description?: string | null;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
/* Relative paths of auxiliary files captured by an install-from-prompt import
|
||||
* (references/, scripts/, …); synced alongside SKILL.md, read-only here. */
|
||||
files: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user