Add author auth, forking, tags, and stats tracking

Introduce token-based author authentication (register/verify API),
   skill forking with EditGate protection, tag metadata on skills,
   and download/push stats. Enhanced push scripts with token auth
   and per-skill filtering. Updated UI with stats, tags, and
   author info on skill cards.
This commit is contained in:
Alejandro Martinez
2026-02-12 14:37:40 +01:00
parent 39d8afb251
commit aa477a553b
80 changed files with 3618 additions and 660 deletions

1
.astro/types.d.ts vendored
View File

@@ -1,2 +1 @@
/// <reference types="astro/client" />
/// <reference path="content.d.ts" />

View File

@@ -1,15 +0,0 @@
---
name: "Commit Pro"
description: "Generates clean, conventional commit messages from staged changes."
allowedTools:
- Bash
---
# Commit Pro
When the user asks you to commit, follow these steps:
1. Run `git diff --cached` to see staged changes
2. Analyze the changes and determine the type: feat, fix, refactor, docs, test, chore
3. Write a concise commit message in conventional commit format
4. Ask the user to confirm before committing

View File

@@ -1,10 +1,7 @@
---
name: "Example Skill"
description: "A sample skill that demonstrates the expected format for Claude Code skills."
allowedTools:
- Read
- Edit
- Write
name: example-skill
description: A sample skill that demonstrates the expected format for Claude Code skills.
allowed-tools: Read, Edit, Write
---
# Example Skill

3
.gitignore vendored
View File

@@ -1 +1,2 @@
node_modules/
node_modules/
data/tokens.json

View File

@@ -1,15 +0,0 @@
---
name: "Commit Pro"
description: "Generates clean, conventional commit messages from staged changes."
allowedTools:
- Bash
---
# Commit Pro
When the user asks you to commit, follow these steps:
1. Run `git diff --cached` to see staged changes
2. Analyze the changes and determine the type: feat, fix, refactor, docs, test, chore
3. Write a concise commit message in conventional commit format
4. Ask the user to confirm before committing

View File

@@ -1,4 +1,6 @@
---
author: Alejandro Martinez
author-email: amartinez2@certinia.com
name: example-skill
description: A sample skill that demonstrates the expected format for Claude Code skills.
allowed-tools: Read, Edit, Write

7
data/stats.json Normal file
View File

@@ -0,0 +1,7 @@
{
"example-skill": {
"downloads": 0,
"pushes": 1,
"lastPushedAt": "2026-02-12T13:27:13.727Z"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{_ as i}from"./_plugin-vue_export-helper.DlAUqK2U.js";import{c as d,a as l,b as c,t as u,e as m,f as p,o as f}from"./runtime-core.esm-bundler.D9KZBfyO.js";const _=m({__name:"DeleteButton",props:{slug:{}},setup(n,{expose:t}){t();const o=n,e=p(!1);async function a(){if(confirm(`Delete "${o.slug}"? This cannot be undone.`)){e.value=!0;try{const s=await fetch(`/api/skills/${o.slug}`,{method:"DELETE"});if(!s.ok&&s.status!==204)throw new Error("Failed to delete");window.location.href="/"}catch{alert("Failed to delete skill."),e.value=!1}}}const r={props:o,deleting:e,handleDelete:a};return Object.defineProperty(r,"__isScriptSetup",{enumerable:!1,value:!0}),r}}),b=["disabled"];function g(n,t,o,e,a,r){return f(),d("button",{onClick:e.handleDelete,disabled:e.deleting,class:"inline-flex items-center gap-1.5 rounded-lg border border-red-500/20 bg-red-500/5 px-3.5 py-2 text-sm font-medium text-red-400 hover:bg-red-500/10 hover:border-red-500/30 disabled:opacity-50 active:scale-[0.97] transition-all"},[t[0]||(t[0]=l("svg",{class:"h-3.5 w-3.5",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor","stroke-width":"2"},[l("path",{"stroke-linecap":"round","stroke-linejoin":"round",d:"m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"})],-1)),c(" "+u(e.deleting?"Deleting...":"Delete"),1)],8,b)}const v=i(_,[["render",g]]);export{v as default};

View File

@@ -0,0 +1 @@
import{c as d,a as o,d as u,l as b,g as p,t as f,w as C,v as _,b as m,T as S,F as T,h as E,i,m as M,o as c}from"./runtime-dom.esm-bundler.A7MyAQcw.js";import{_ as V}from"./_plugin-vue_export-helper.DlAUqK2U.js";const B=E({__name:"EditGate",props:{slug:{},authorEmail:{},authorName:{},authorHasToken:{type:Boolean}},setup(h,{expose:e}){e();const r=h,t=i(!1),s=i(""),a=i(""),n=i(!1),v=i();async function x(){if(!r.authorEmail||!r.authorHasToken){window.location.href=`/${r.slug}/edit`;return}const l=localStorage.getItem("skillshere-token")||"";if(l)try{if((await fetch("/api/auth/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:r.authorEmail,token:l})})).ok){localStorage.setItem("skillshere-token",l),window.location.href=`/${r.slug}/edit`;return}}catch{}t.value=!0,a.value="",s.value="",M(()=>v.value?.focus())}async function g(){n.value=!0,a.value="";try{const l=await fetch("/api/auth/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:r.authorEmail,token:s.value})});if(!l.ok){const y=await l.json();a.value=y.error||"Invalid token";return}localStorage.setItem("skillshere-token",s.value),window.location.href=`/${r.slug}/edit`}catch{a.value="Could not verify token"}finally{n.value=!1}}function w(){t.value=!1,window.location.href=`/new?from=${encodeURIComponent(r.slug)}`}const k={props:r,showModal:t,token:s,error:a,verifying:n,tokenInput:v,handleClick:x,verify:g,forkSkill:w};return Object.defineProperty(k,"__isScriptSetup",{enumerable:!1,value:!0}),k}}),I={class:"w-full max-w-md rounded-2xl border border-white/[0.08] bg-[var(--color-surface-200)] p-6 shadow-2xl"},N={class:"text-sm text-gray-500 mb-4"},j={class:"text-gray-300"},O={key:0,class:"mt-2 text-sm text-red-400"},A={class:"mt-4 flex items-center gap-3"},H=["disabled"],P={key:0,class:"h-4 w-4 animate-spin",fill:"none",viewBox:"0 0 24 24"};function F(h,e,r,t,s,a){return c(),d(T,null,[o("button",{onClick:t.handleClick,class:"inline-flex items-center gap-1.5 rounded-lg border border-white/[0.08] bg-surface-200 px-3.5 py-2 text-sm font-medium text-gray-300 hover:border-white/[0.15] hover:text-white transition-all"},[...e[3]||(e[3]=[o("svg",{class:"h-3.5 w-3.5",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor","stroke-width":"2"},[o("path",{"stroke-linecap":"round","stroke-linejoin":"round",d:"m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"})],-1),u(" Edit ",-1)])]),(c(),b(S,{to:"body"},[t.showModal?(c(),d("div",{key:0,class:"fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm",onClick:e[2]||(e[2]=p(n=>t.showModal=!1,["self"]))},[o("div",I,[e[7]||(e[7]=o("h3",{class:"text-lg font-semibold text-white mb-1"},"Author Verification",-1)),o("p",N,[e[4]||(e[4]=u(" This skill is owned by ",-1)),o("strong",j,f(r.authorName||r.authorEmail),1),e[5]||(e[5]=u(". Enter your token to edit. ",-1))]),o("form",{onSubmit:p(t.verify,["prevent"])},[C(o("input",{ref:"tokenInput","onUpdate:modelValue":e[0]||(e[0]=n=>t.token=n),type:"password",placeholder:"Paste your author token...",class:"w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 font-mono focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"},null,512),[[_,t.token]]),t.error?(c(),d("p",O,f(t.error),1)):m("",!0),o("div",A,[o("button",{type:"submit",disabled:t.verifying||!t.token,class:"inline-flex items-center gap-2 rounded-xl bg-[var(--color-accent-500)] px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-[var(--color-accent-500)]/20 hover:bg-[var(--color-accent-600)] disabled:opacity-50 active:scale-[0.97] transition-all"},[t.verifying?(c(),d("svg",P,[...e[6]||(e[6]=[o("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"},null,-1),o("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"},null,-1)])])):m("",!0),u(" "+f(t.verifying?"Verifying...":"Continue to Edit"),1)],8,H),o("button",{type:"button",onClick:t.forkSkill,class:"text-sm text-[var(--color-accent-400)] hover:text-[var(--color-accent-300)] transition-colors"}," Fork instead "),o("button",{type:"button",onClick:e[1]||(e[1]=n=>t.showModal=!1),class:"ml-auto text-sm text-gray-600 hover:text-gray-300 transition-colors"}," Cancel ")])],32)])])):m("",!0)]))],64)}const D=V(B,[["render",F]]);export{D as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{v as m}from"./runtime-dom.esm-bundler.ALO2-icn.js";import{_ as f}from"./_plugin-vue_export-helper.DlAUqK2U.js";import{c as _,a as r,w as h,e as v,g as x,f as y,o as w}from"./runtime-core.esm-bundler.D9KZBfyO.js";const k=v({__name:"SkillSearch",setup(c,{expose:e}){e();const n=y("");x(n,a=>{const o=a.toLowerCase().trim();document.querySelectorAll("[data-skill]").forEach(s=>{const i=s.dataset.name||"",d=s.dataset.description||"",u=s.dataset.tools||"",p=!o||i.includes(o)||d.includes(o)||u.includes(o);s.style.display=p?"":"none"})});const t={query:n};return Object.defineProperty(t,"__isScriptSetup",{enumerable:!1,value:!0}),t}}),b={class:"mb-6 max-w-md"},S={class:"relative"};function g(c,e,n,t,a,o){return w(),_("div",b,[r("div",S,[e[1]||(e[1]=r("svg",{class:"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-600",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor","stroke-width":"2"},[r("path",{"stroke-linecap":"round","stroke-linejoin":"round",d:"m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"})],-1)),h(r("input",{"onUpdate:modelValue":e[0]||(e[0]=l=>t.query=l),type:"text",placeholder:"Search skills...",class:"w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"},null,512),[[m,t.query]])])])}const j=f(k,[["render",g]]);export{j as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{c as m,d as y}from"./runtime-dom.esm-bundler.ALO2-icn.js";import{e as v,i as r,S}from"./runtime-core.esm-bundler.D9KZBfyO.js";const g=()=>{},A=v({props:{value:String,name:String,hydrate:{type:Boolean,default:!0}},setup({name:t,value:e,hydrate:a}){if(!e)return()=>null;let c=a?"astro-slot":"astro-static-slot";return()=>r(c,{name:t,innerHTML:e})}});var h=A;let p=new WeakMap;var M=t=>async(e,a,c,{client:l})=>{if(!t.hasAttribute("ssr"))return;const f=e.name?`${e.name} Host`:void 0,i={};for(const[n,o]of Object.entries(c))i[n]=()=>r(h,{value:o,name:n==="default"?void 0:n});const u=l!=="only",d=u?m:y;let s=p.get(t);if(s)s.props=a,s.slots=i,s.component.$forceUpdate();else{s={props:a,slots:i};const n=d({name:f,render(){let o=r(e,s.props,s.slots);return s.component=this,b(e.setup)&&(o=r(S,null,o)),o}});n.config.idPrefix=t.getAttribute("prefix")??void 0,await g(),n.mount(t,u),p.set(t,s),t.addEventListener("astro:unmount",()=>n.unmount(),{once:!0})}};function b(t){const e=t?.constructor;return e&&e.name==="AsyncFunction"}export{M as default};

1
dist/client/_astro/client.BnTlSu1B.js vendored Normal file
View File

@@ -0,0 +1 @@
import{h as m,s as r,u as y,x as v,S}from"./runtime-dom.esm-bundler.A7MyAQcw.js";const g=()=>{},h=m({props:{value:String,name:String,hydrate:{type:Boolean,default:!0}},setup({name:t,value:s,hydrate:o}){if(!s)return()=>null;let c=o?"astro-slot":"astro-static-slot";return()=>r(c,{name:t,innerHTML:s})}});var A=h;let p=new WeakMap;var H=t=>async(s,o,c,{client:l})=>{if(!t.hasAttribute("ssr"))return;const f=s.name?`${s.name} Host`:void 0,u={};for(const[n,a]of Object.entries(c))u[n]=()=>r(A,{value:a,name:n==="default"?void 0:n});const i=l!=="only",d=i?y:v;let e=p.get(t);if(e)e.props=o,e.slots=u,e.component.$forceUpdate();else{e={props:o,slots:u};const n=d({name:f,render(){let a=r(s,e.props,e.slots);return e.component=this,b(s.setup)&&(a=r(S,null,a)),a}});n.config.idPrefix=t.getAttribute("prefix")??void 0,await g(),n.mount(t,i),p.set(t,e),t.addEventListener("astro:unmount",()=>n.unmount(),{once:!0})}};function b(t){const s=t?.constructor;return s&&s.name==="AsyncFunction"}export{H as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

21
dist/client/favicon.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1 +1 @@
export { c as createExports, a as start } from './chunks/_@astrojs-ssr-adapter_DIu76Dvd.mjs';
export { c as createExports, a as start } from './chunks/_@astrojs-ssr-adapter_BeL8VyJ8.mjs';

View File

@@ -1,7 +1,7 @@
import { q as decryptString, v as createSlotValueFromString, w as isAstroComponentFactory, k as renderComponent, r as renderTemplate, R as ROUTE_TYPE_HEADER, x as REROUTE_DIRECTIVE_HEADER, A as AstroError, y as i18nNoLocaleFoundInPath, z as ResponseSentError, B as ActionNotFoundError, C as MiddlewareNoDataOrNextCalled, D as MiddlewareNotAResponse, G as originPathnameSymbol, H as RewriteWithBodyUsed, J as GetStaticPathsRequired, K as InvalidGetStaticPathsReturn, O as InvalidGetStaticPathsEntry, P as GetStaticPathsExpectedParams, Q as GetStaticPathsInvalidRouteParam, S as PageNumberParamNotFound, T as DEFAULT_404_COMPONENT, V as NoMatchingStaticPathFound, W as PrerenderDynamicEndpointPathCollide, X as ReservedSlotName, Y as renderSlotToString, Z as renderJSX, _ as chunkToString, $ as isRenderInstruction, a0 as ForbiddenRewrite, a1 as SessionStorageInitError, a2 as SessionStorageSaveError, a3 as ASTRO_VERSION, a4 as CspNotEnabled, a5 as LocalsReassigned, a6 as generateCspDigest, a7 as PrerenderClientAddressNotAvailable, a8 as clientAddressSymbol, a9 as ClientAddressNotAvailable, aa as StaticClientAddressNotAvailable, ab as AstroResponseHeadersReassigned, ac as responseSentSymbol$1, ad as renderPage, ae as REWRITE_DIRECTIVE_HEADER_KEY, af as REWRITE_DIRECTIVE_HEADER_VALUE, ag as renderEndpoint, ah as LocalsNotAnObject, ai as FailedToFindPageMapSSR, aj as REROUTABLE_STATUS_CODES, ak as nodeRequestAbortControllerCleanupSymbol } from './astro/server_B-2LxKLH.mjs';
import { q as decryptString, v as createSlotValueFromString, w as isAstroComponentFactory, k as renderComponent, r as renderTemplate, R as ROUTE_TYPE_HEADER, x as REROUTE_DIRECTIVE_HEADER, A as AstroError, y as i18nNoLocaleFoundInPath, z as ResponseSentError, B as ActionNotFoundError, C as MiddlewareNoDataOrNextCalled, D as MiddlewareNotAResponse, G as originPathnameSymbol, H as RewriteWithBodyUsed, J as GetStaticPathsRequired, K as InvalidGetStaticPathsReturn, O as InvalidGetStaticPathsEntry, P as GetStaticPathsExpectedParams, Q as GetStaticPathsInvalidRouteParam, S as PageNumberParamNotFound, T as DEFAULT_404_COMPONENT, V as NoMatchingStaticPathFound, W as PrerenderDynamicEndpointPathCollide, X as ReservedSlotName, Y as renderSlotToString, Z as renderJSX, _ as chunkToString, $ as isRenderInstruction, a0 as ForbiddenRewrite, a1 as SessionStorageInitError, a2 as SessionStorageSaveError, a3 as ASTRO_VERSION, a4 as CspNotEnabled, a5 as LocalsReassigned, a6 as generateCspDigest, a7 as PrerenderClientAddressNotAvailable, a8 as clientAddressSymbol, a9 as ClientAddressNotAvailable, aa as StaticClientAddressNotAvailable, ab as AstroResponseHeadersReassigned, ac as responseSentSymbol$1, ad as renderPage, ae as REWRITE_DIRECTIVE_HEADER_KEY, af as REWRITE_DIRECTIVE_HEADER_VALUE, ag as renderEndpoint, ah as LocalsNotAnObject, ai as FailedToFindPageMapSSR, aj as REROUTABLE_STATUS_CODES, ak as nodeRequestAbortControllerCleanupSymbol } from './astro/server_CF97kUu8.mjs';
import colors from 'piccolore';
import 'clsx';
import { A as ActionError, d as deserializeActionResult, s as serializeActionResult, a as ACTION_RPC_ROUTE_PATTERN, b as ACTION_QUERY_PARAMS, g as getActionQueryString, D as DEFAULT_404_ROUTE, c as default404Instance, N as NOOP_MIDDLEWARE_FN, e as ensure404Route } from './astro-designed-error-pages_B_BAqCrl.mjs';
import { A as ActionError, d as deserializeActionResult, s as serializeActionResult, a as ACTION_RPC_ROUTE_PATTERN, b as ACTION_QUERY_PARAMS, g as getActionQueryString, D as DEFAULT_404_ROUTE, c as default404Instance, N as NOOP_MIDDLEWARE_FN, e as ensure404Route } from './astro-designed-error-pages_DSexancP.mjs';
import 'es-module-lexer';
import buffer from 'node:buffer';
import crypto$1 from 'node:crypto';

View File

@@ -1,24 +0,0 @@
import { e as createComponent, n as renderHead, o as renderSlot, r as renderTemplate, h as createAstro } from './astro/server_B-2LxKLH.mjs';
import 'piccolore';
import 'clsx';
/* empty css */
const $$Astro = createAstro();
const $$Base = createComponent(($$result, $$props, $$slots) => {
const Astro2 = $$result.createAstro($$Astro, $$props, $$slots);
Astro2.self = $$Base;
const { title = "Skillit" } = Astro2.props;
return renderTemplate`<html lang="en"> <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"><title>${title}</title>${renderHead()}</head> <body class="min-h-screen font-sans text-gray-300 antialiased"> <!-- Subtle gradient glow --> <div class="pointer-events-none fixed inset-0 overflow-hidden"> <div class="absolute -top-40 left-1/2 -translate-x-1/2 h-80 w-[600px] rounded-full bg-accent-500/[0.07] blur-[120px]"></div> </div> <nav class="relative z-50 border-b border-white/[0.06] bg-surface-50/80 backdrop-blur-xl"> <div class="mx-auto max-w-5xl flex items-center justify-between px-6 py-4"> <a href="/" class="group flex items-center gap-2.5"> <div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-accent-500 to-accent-600 shadow-lg shadow-accent-500/20"> <svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"></path> </svg> </div> <span class="text-lg font-bold tracking-tight text-white group-hover:text-accent-400 transition-colors">skillit</span> </a> <a href="/new" class="inline-flex items-center gap-1.5 rounded-lg bg-accent-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-accent-500/20 hover:bg-accent-600 hover:shadow-accent-500/30 active:scale-[0.97] transition-all"> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"></path> </svg>
New Skill
</a> </div> </nav> <main class="relative mx-auto max-w-5xl px-6 py-10"> ${renderSlot($$result, $$slots["default"])} </main> </body></html>`;
}, "/Users/alex/projects/skillit/src/layouts/Base.astro", void 0);
const _export_sfc = (sfc, props) => {
const target = sfc.__vccOpts || sfc;
for (const [key, val] of props) {
target[key] = val;
}
return target;
};
export { $$Base as $, _export_sfc as _ };

View File

@@ -0,0 +1,24 @@
import { e as createAstro, f as createComponent, n as renderHead, o as renderSlot, r as renderTemplate } from './astro/server_CF97kUu8.mjs';
import 'piccolore';
import 'clsx';
/* empty css */
const $$Astro = createAstro("https://skills.here.run.place");
const $$Base = createComponent(($$result, $$props, $$slots) => {
const Astro2 = $$result.createAstro($$Astro, $$props, $$slots);
Astro2.self = $$Base;
const { title = "Skills Here" } = Astro2.props;
return renderTemplate`<html lang="en"> <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"><link rel="icon" type="image/svg+xml" href="/favicon.svg"><title>${title}</title>${renderHead()}</head> <body class="min-h-screen font-sans text-gray-300 antialiased"> <!-- Subtle gradient glow --> <div class="pointer-events-none fixed inset-0 overflow-hidden"> <div class="absolute -top-40 left-1/2 -translate-x-1/2 h-80 w-[600px] rounded-full bg-accent-500/[0.07] blur-[120px]"></div> </div> <nav class="relative z-50 border-b border-white/[0.06] bg-surface-50/80 backdrop-blur-xl"> <div class="mx-auto max-w-6xl flex items-center justify-between px-6 py-4"> <a href="/" class="group flex items-center gap-2.5"> <img src="/favicon.svg" alt="Skills Here" class="h-8 w-8"> <span class="text-lg font-bold tracking-tight text-white group-hover:text-accent-400 transition-colors">Skills Here</span> </a> <a href="/new" class="inline-flex items-center gap-1.5 rounded-lg bg-accent-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-accent-500/20 hover:bg-accent-600 hover:shadow-accent-500/30 active:scale-[0.97] transition-all"> <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"></path> </svg>
New Skill
</a> </div> </nav> <main class="relative mx-auto max-w-6xl px-6 py-10"> ${renderSlot($$result, $$slots["default"])} </main> </body></html>`;
}, "/Users/alex/projects/skillit/src/layouts/Base.astro", void 0);
const _export_sfc = (sfc, props) => {
const target = sfc.__vccOpts || sfc;
for (const [key, val] of props) {
target[key] = val;
}
return target;
};
export { $$Base as $, _export_sfc as _ };

View File

@@ -1,4 +1,4 @@
import { al as NOOP_MIDDLEWARE_HEADER, am as REDIRECT_STATUS_CODES, A as AstroError, an as ActionsReturnedInvalidDataError, T as DEFAULT_404_COMPONENT } from './astro/server_B-2LxKLH.mjs';
import { al as NOOP_MIDDLEWARE_HEADER, am as REDIRECT_STATUS_CODES, A as AstroError, an as ActionsReturnedInvalidDataError, T as DEFAULT_404_COMPONENT } from './astro/server_CF97kUu8.mjs';
import { parse, stringify } from 'devalue';
import { escape } from 'html-escaper';
@@ -12,7 +12,7 @@ const ACTION_QUERY_PARAMS$1 = {
actionName: "_action"};
const ACTION_RPC_ROUTE_PATTERN = "/_actions/[...path]";
const __vite_import_meta_env__ = {"ASSETS_PREFIX": undefined, "BASE_URL": "/", "DEV": false, "MODE": "production", "PROD": true, "SITE": undefined, "SSR": true};
const __vite_import_meta_env__ = {"ASSETS_PREFIX": undefined, "BASE_URL": "/", "DEV": false, "MODE": "production", "PROD": true, "SITE": "https://skills.here.run.place", "SSR": true};
const ACTION_QUERY_PARAMS = ACTION_QUERY_PARAMS$1;
const codeToStatusMap = {
// Implemented from IANA HTTP Status Code Registry

View File

@@ -455,7 +455,7 @@ Use import.meta.glob instead: https://vitejs.dev/guide/features.html#glob-import
}
function createAstro(site) {
return {
site: void 0,
site: new URL(site) ,
generator: `Astro v${ASTRO_VERSION}`,
glob: createAstroGlobFn()
};
@@ -2837,4 +2837,4 @@ function spreadAttributes(values = {}, _name, { class: scopedClassName } = {}) {
return markHTMLString(output);
}
export { isRenderInstruction as $, AstroError as A, ActionNotFoundError as B, MiddlewareNoDataOrNextCalled as C, MiddlewareNotAResponse as D, ExpectedImage as E, FailedToFetchRemoteImageDimensions as F, originPathnameSymbol as G, RewriteWithBodyUsed as H, IncompatibleDescriptorOptions as I, GetStaticPathsRequired as J, InvalidGetStaticPathsReturn as K, LocalImageUsedWrongly as L, MissingImageDimension as M, NoImageMetadata as N, InvalidGetStaticPathsEntry as O, GetStaticPathsExpectedParams as P, GetStaticPathsInvalidRouteParam as Q, ROUTE_TYPE_HEADER as R, PageNumberParamNotFound as S, DEFAULT_404_COMPONENT as T, UnsupportedImageFormat as U, NoMatchingStaticPathFound as V, PrerenderDynamicEndpointPathCollide as W, ReservedSlotName as X, renderSlotToString as Y, renderJSX as Z, chunkToString as _, UnsupportedImageConversion as a, ForbiddenRewrite as a0, SessionStorageInitError as a1, SessionStorageSaveError as a2, ASTRO_VERSION as a3, CspNotEnabled as a4, LocalsReassigned as a5, generateCspDigest as a6, PrerenderClientAddressNotAvailable as a7, clientAddressSymbol as a8, ClientAddressNotAvailable as a9, StaticClientAddressNotAvailable as aa, AstroResponseHeadersReassigned as ab, responseSentSymbol as ac, renderPage as ad, REWRITE_DIRECTIVE_HEADER_KEY as ae, REWRITE_DIRECTIVE_HEADER_VALUE as af, renderEndpoint as ag, LocalsNotAnObject as ah, FailedToFindPageMapSSR as ai, REROUTABLE_STATUS_CODES as aj, nodeRequestAbortControllerCleanupSymbol as ak, NOOP_MIDDLEWARE_HEADER as al, REDIRECT_STATUS_CODES as am, ActionsReturnedInvalidDataError as an, MissingSharp as ao, ExpectedImageOptions as b, ExpectedNotESMImage as c, InvalidImageService as d, createComponent as e, ImageMissingAlt as f, addAttribute as g, createAstro as h, ExperimentalFontsNotEnabled as i, FontFamilyNotFound as j, renderComponent as k, renderScript as l, maybeRenderHead as m, renderHead as n, renderSlot as o, decodeKey as p, decryptString as q, renderTemplate as r, spreadAttributes as s, toStyleString as t, unescapeHTML as u, createSlotValueFromString as v, isAstroComponentFactory as w, REROUTE_DIRECTIVE_HEADER as x, i18nNoLocaleFoundInPath as y, ResponseSentError as z };
export { isRenderInstruction as $, AstroError as A, ActionNotFoundError as B, MiddlewareNoDataOrNextCalled as C, MiddlewareNotAResponse as D, ExpectedImage as E, FailedToFetchRemoteImageDimensions as F, originPathnameSymbol as G, RewriteWithBodyUsed as H, IncompatibleDescriptorOptions as I, GetStaticPathsRequired as J, InvalidGetStaticPathsReturn as K, LocalImageUsedWrongly as L, MissingImageDimension as M, NoImageMetadata as N, InvalidGetStaticPathsEntry as O, GetStaticPathsExpectedParams as P, GetStaticPathsInvalidRouteParam as Q, ROUTE_TYPE_HEADER as R, PageNumberParamNotFound as S, DEFAULT_404_COMPONENT as T, UnsupportedImageFormat as U, NoMatchingStaticPathFound as V, PrerenderDynamicEndpointPathCollide as W, ReservedSlotName as X, renderSlotToString as Y, renderJSX as Z, chunkToString as _, UnsupportedImageConversion as a, ForbiddenRewrite as a0, SessionStorageInitError as a1, SessionStorageSaveError as a2, ASTRO_VERSION as a3, CspNotEnabled as a4, LocalsReassigned as a5, generateCspDigest as a6, PrerenderClientAddressNotAvailable as a7, clientAddressSymbol as a8, ClientAddressNotAvailable as a9, StaticClientAddressNotAvailable as aa, AstroResponseHeadersReassigned as ab, responseSentSymbol as ac, renderPage as ad, REWRITE_DIRECTIVE_HEADER_KEY as ae, REWRITE_DIRECTIVE_HEADER_VALUE as af, renderEndpoint as ag, LocalsNotAnObject as ah, FailedToFindPageMapSSR as ai, REROUTABLE_STATUS_CODES as aj, nodeRequestAbortControllerCleanupSymbol as ak, NOOP_MIDDLEWARE_HEADER as al, REDIRECT_STATUS_CODES as am, ActionsReturnedInvalidDataError as an, MissingSharp as ao, ExpectedImageOptions as b, ExpectedNotESMImage as c, InvalidImageService as d, createAstro as e, createComponent as f, ImageMissingAlt as g, addAttribute as h, ExperimentalFontsNotEnabled as i, FontFamilyNotFound as j, renderComponent as k, renderScript as l, maybeRenderHead as m, renderHead as n, renderSlot as o, decodeKey as p, decryptString as q, renderTemplate as r, spreadAttributes as s, toStyleString as t, unescapeHTML as u, createSlotValueFromString as v, isAstroComponentFactory as w, REROUTE_DIRECTIVE_HEADER as x, i18nNoLocaleFoundInPath as y, ResponseSentError as z };

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
import { i as isRemoteAllowed, j as joinPaths, a as isRemotePath, r as removeQueryString, b as isParentDirectory } from './remote_B3W5fv4r.mjs';
import { A as AstroError, E as ExpectedImage, L as LocalImageUsedWrongly, M as MissingImageDimension, U as UnsupportedImageFormat, I as IncompatibleDescriptorOptions, a as UnsupportedImageConversion, t as toStyleString, N as NoImageMetadata, F as FailedToFetchRemoteImageDimensions, b as ExpectedImageOptions, c as ExpectedNotESMImage, d as InvalidImageService, e as createComponent, f as ImageMissingAlt, m as maybeRenderHead, g as addAttribute, s as spreadAttributes, r as renderTemplate, h as createAstro, i as ExperimentalFontsNotEnabled, j as FontFamilyNotFound, u as unescapeHTML } from './astro/server_B-2LxKLH.mjs';
import { A as AstroError, E as ExpectedImage, L as LocalImageUsedWrongly, M as MissingImageDimension, U as UnsupportedImageFormat, I as IncompatibleDescriptorOptions, a as UnsupportedImageConversion, t as toStyleString, N as NoImageMetadata, F as FailedToFetchRemoteImageDimensions, b as ExpectedImageOptions, c as ExpectedNotESMImage, d as InvalidImageService, e as createAstro, f as createComponent, g as ImageMissingAlt, m as maybeRenderHead, h as addAttribute, s as spreadAttributes, r as renderTemplate, i as ExperimentalFontsNotEnabled, j as FontFamilyNotFound, u as unescapeHTML } from './astro/server_CF97kUu8.mjs';
import 'clsx';
import * as mime from 'mrmime';
import 'piccolore';
@@ -1434,7 +1434,7 @@ async function getConfiguredImageService() {
if (!globalThis?.astroAsset?.imageService) {
const { default: service } = await import(
// @ts-expect-error
'./sharp_CRCimLOL.mjs'
'./sharp_D9uxjd11.mjs'
).catch((e) => {
const error = new AstroError(InvalidImageService);
error.cause = e;
@@ -1589,7 +1589,7 @@ async function getImage$1(options, imageConfig) {
};
}
const $$Astro$2 = createAstro();
const $$Astro$2 = createAstro("https://skills.here.run.place");
const $$Image = createComponent(async ($$result, $$props, $$slots) => {
const Astro2 = $$result.createAstro($$Astro$2, $$props, $$slots);
Astro2.self = $$Image;
@@ -1618,7 +1618,7 @@ const $$Image = createComponent(async ($$result, $$props, $$slots) => {
return renderTemplate`${maybeRenderHead()}<img${addAttribute(image.src, "src")}${spreadAttributes(attributes)}${addAttribute(className, "class")}>`;
}, "/Users/alex/projects/skillit/node_modules/astro/components/Image.astro", void 0);
const $$Astro$1 = createAstro();
const $$Astro$1 = createAstro("https://skills.here.run.place");
const $$Picture = createComponent(async ($$result, $$props, $$slots) => {
const Astro2 = $$result.createAstro($$Astro$1, $$props, $$slots);
Astro2.self = $$Picture;
@@ -1728,7 +1728,7 @@ function checkWeight(input, target) {
return input === target;
}
const $$Astro = createAstro();
const $$Astro = createAstro("https://skills.here.run.place");
const $$Font = createComponent(($$result, $$props, $$slots) => {
const Astro2 = $$result.createAstro($$Astro, $$props, $$slots);
Astro2.self = $$Font;

View File

@@ -1,5 +1,5 @@
import { A as AstroError, ao as MissingSharp } from './astro/server_B-2LxKLH.mjs';
import { b as baseService, p as parseQuality } from './node_WXNYuHqd.mjs';
import { A as AstroError, ao as MissingSharp } from './astro/server_CF97kUu8.mjs';
import { b as baseService, p as parseQuality } from './node_HH9e2ntY.mjs';
let sharp;
const qualityTable = {

View File

@@ -31,6 +31,10 @@ function parseSkill(slug, raw) {
"disable-model-invocation": Boolean(data["disable-model-invocation"]),
context: data.context || "",
agent: data.agent || "",
author: data.author || "",
"author-email": data["author-email"] || "",
"fork-of": data["fork-of"] || "",
tags: parseTools(data.tags),
hooks: typeof data.hooks === "object" && data.hooks !== null ? data.hooks : null,
content: content.trim(),
raw
@@ -49,6 +53,14 @@ async function listSkills() {
}
return skills.sort((a, b) => a.name.localeCompare(b.name));
}
async function getAllTags() {
const all = await listSkills();
return [...new Set(all.flatMap((s) => s.tags))].sort();
}
async function getForksOf(slug) {
const all = await listSkills();
return all.filter((s) => s["fork-of"] === slug);
}
async function getSkill(slug) {
try {
const raw = await fs.readFile(skillPath(slug), "utf-8");
@@ -94,4 +106,4 @@ async function deleteSkill(slug) {
await fs.unlink(dest);
}
export { createSkill as c, deleteSkill as d, getSkill as g, isValidSlug as i, listSkills as l, updateSkill as u };
export { getAllTags as a, getForksOf as b, createSkill as c, deleteSkill as d, getSkill as g, isValidSlug as i, listSkills as l, updateSkill as u };

46
dist/server/chunks/stats_CaDi9y9J.mjs vendored Normal file
View File

@@ -0,0 +1,46 @@
import fs from 'node:fs/promises';
import path from 'node:path';
const STATS_FILE = path.resolve(process.env.STATS_FILE || "data/stats.json");
async function readStore() {
try {
const raw = await fs.readFile(STATS_FILE, "utf-8");
return JSON.parse(raw);
} catch {
return {};
}
}
async function writeStore(store) {
const dir = path.dirname(STATS_FILE);
await fs.mkdir(dir, { recursive: true });
const tmp = STATS_FILE + ".tmp";
await fs.writeFile(tmp, JSON.stringify(store, null, 2), "utf-8");
await fs.rename(tmp, STATS_FILE);
}
function ensure(store, slug) {
if (!store[slug]) {
store[slug] = { downloads: 0, pushes: 0, lastPushedAt: null };
}
return store[slug];
}
async function recordDownload(slug) {
const store = await readStore();
ensure(store, slug).downloads++;
await writeStore(store);
}
async function recordPush(slug) {
const store = await readStore();
const entry = ensure(store, slug);
entry.pushes++;
entry.lastPushedAt = (/* @__PURE__ */ new Date()).toISOString();
await writeStore(store);
}
async function getStatsForSlug(slug) {
const store = await readStore();
return store[slug] || { downloads: 0, pushes: 0, lastPushedAt: null };
}
async function getAllStats() {
return readStore();
}
export { recordDownload as a, getAllStats as b, getStatsForSlug as g, recordPush as r };

309
dist/server/chunks/sync_BEq_wzpT.mjs vendored Normal file
View File

@@ -0,0 +1,309 @@
import { l as listSkills } from './skills_BacVQUiS.mjs';
function isPowerShell(request) {
const ua = request.headers.get("user-agent") || "";
return /PowerShell/i.test(ua);
}
async function buildPushScript(baseUrl, skillsDir) {
const lines = [
"#!/usr/bin/env bash",
"set -euo pipefail",
"",
`SKILLS_DIR="${skillsDir}"`,
`BASE_URL="${baseUrl}"`,
'FILTER="${1:-}"',
'TOKEN_FILE="$HOME/.claude/skills.here-token"',
"",
"# Get git author if available",
'AUTHOR_NAME=$(git config user.name 2>/dev/null || echo "")',
'AUTHOR_EMAIL=$(git config user.email 2>/dev/null || echo "")',
"",
"# Load or register token",
'TOKEN=""',
'if [ -f "$TOKEN_FILE" ]; then',
' TOKEN=$(cat "$TOKEN_FILE")',
'elif [ -n "$AUTHOR_EMAIL" ]; then',
' echo "No token found. Registering with $AUTHOR_EMAIL..."',
" REGISTER_RESPONSE=$(curl -sS -X POST \\",
' -H "Content-Type: application/json" \\',
' -d "{\\"email\\": \\"$AUTHOR_EMAIL\\", \\"name\\": \\"$AUTHOR_NAME\\"}" \\',
' -w "\\n%{http_code}" \\',
' "$BASE_URL/api/auth/register")',
` REGISTER_BODY=$(echo "$REGISTER_RESPONSE" | sed '$d')`,
' REGISTER_STATUS=$(echo "$REGISTER_RESPONSE" | tail -1)',
' if [ "$REGISTER_STATUS" = "201" ]; then',
' TOKEN=$(echo "$REGISTER_BODY" | jq -r .token)',
' mkdir -p "$(dirname "$TOKEN_FILE")"',
' echo "$TOKEN" > "$TOKEN_FILE"',
' chmod 600 "$TOKEN_FILE"',
' echo " Token saved to $TOKEN_FILE"',
' elif [ "$REGISTER_STATUS" = "409" ]; then',
' echo " Email already registered. Place your token in $TOKEN_FILE"',
' echo " Continuing without token (unprotected skills only)..."',
" else",
' echo " Registration failed ($REGISTER_STATUS): $REGISTER_BODY"',
' echo " Continuing without token (unprotected skills only)..."',
" fi",
"fi",
"",
'if [ ! -d "$SKILLS_DIR" ]; then',
' echo "No skills directory found at $SKILLS_DIR"',
" exit 1",
"fi",
"",
'AUTH_HEADER=""',
'if [ -n "$TOKEN" ]; then',
' AUTH_HEADER="Authorization: Bearer $TOKEN"',
"fi",
"",
"push_skill() {",
' local file="$1"',
' local slug=$(basename "$file" .md)',
' local content=$(cat "$file")',
"",
" # Inject author into frontmatter if available and not already present",
' if [ -n "$AUTHOR_EMAIL" ] && ! echo "$content" | grep -q "^author-email:"; then',
` content=$(echo "$content" | awk -v name="$AUTHOR_NAME" -v email="$AUTHOR_EMAIL" 'NR==1 && /^---$/{print; if (name) print "author: " name; print "author-email: " email; next} {print}')`,
" fi",
"",
" # Build curl auth args",
' local auth_args=""',
' if [ -n "$AUTH_HEADER" ]; then',
' auth_args="-H \\"$AUTH_HEADER\\""',
" fi",
"",
" # Try PUT (update), fallback to POST (create)",
" local response",
' if [ -n "$TOKEN" ]; then',
' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\',
' -H "Content-Type: application/json" \\',
' -H "Authorization: Bearer $TOKEN" \\',
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills/$slug")',
" else",
' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills/$slug")',
" fi",
"",
' if [ "$response" = "403" ]; then',
' echo " ✗ $slug (permission denied — token missing or invalid)"',
" return 1",
" fi",
"",
' if [ "$response" = "404" ]; then',
' if [ -n "$TOKEN" ]; then',
' local post_status=$(curl -sS -o /dev/null -w "%{http_code}" -X POST \\',
' -H "Content-Type: application/json" \\',
' -H "Authorization: Bearer $TOKEN" \\',
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills")',
" else",
' local post_status=$(curl -sS -o /dev/null -w "%{http_code}" -X POST \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills")',
" fi",
' if [ "$post_status" = "403" ]; then',
' echo " ✗ $slug (permission denied — token missing or invalid)"',
" return 1",
" fi",
" fi",
"",
' echo " ✓ $slug"',
"}",
"",
"count=0",
"failed=0",
'if [ -n "$FILTER" ]; then',
" # Push a specific skill",
' file="$SKILLS_DIR/${FILTER%.md}.md"',
' if [ ! -f "$file" ]; then',
' echo "Skill not found: $file"',
" exit 1",
" fi",
' if push_skill "$file"; then',
" count=1",
" else",
" failed=1",
" fi",
"else",
" # Push all skills",
' for file in "$SKILLS_DIR"/*.md; do',
' [ -f "$file" ] || continue',
' if push_skill "$file"; then',
" count=$((count + 1))",
" else",
" failed=$((failed + 1))",
" fi",
" done",
"fi",
"",
'echo "Pushed $count skill(s) to $BASE_URL"',
'if [ "$failed" -gt 0 ]; then',
' echo "$failed skill(s) failed (permission denied)"',
"fi",
""
];
return lines.join("\n");
}
async function buildSyncScript(baseUrl, skillsDir) {
const skills = await listSkills();
const lines = [
"#!/usr/bin/env bash",
"set -euo pipefail",
"",
`SKILLS_DIR="${skillsDir}"`,
'mkdir -p "$SKILLS_DIR"',
""
];
if (skills.length === 0) {
lines.push('echo "No skills available to sync."');
} else {
lines.push(`echo "Syncing ${skills.length} skill(s) from ${baseUrl}..."`);
lines.push("");
for (const skill of skills) {
const skillUrl = `${baseUrl}/${skill.slug}`;
lines.push(`curl -fsSL "${skillUrl}" -o "$SKILLS_DIR/${skill.slug}.md"`);
lines.push(`echo " ✓ ${skill.name}"`);
}
lines.push("");
lines.push('echo "Done! Skills synced to $SKILLS_DIR"');
}
lines.push("");
return lines.join("\n");
}
async function buildSyncScriptPS(baseUrl, skillsDir) {
const skills = await listSkills();
const lines = [
'$ErrorActionPreference = "Stop"',
"",
`$SkillsDir = "${skillsDir}"`,
"New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null",
""
];
if (skills.length === 0) {
lines.push('Write-Host "No skills available to sync."');
} else {
lines.push(`Write-Host "Syncing ${skills.length} skill(s) from ${baseUrl}..."`);
lines.push("");
for (const skill of skills) {
const skillUrl = `${baseUrl}/${skill.slug}`;
lines.push(`Invoke-WebRequest -Uri "${skillUrl}" -OutFile (Join-Path $SkillsDir "${skill.slug}.md")`);
lines.push(`Write-Host " ✓ ${skill.name}"`);
}
lines.push("");
lines.push('Write-Host "Done! Skills synced to $SkillsDir"');
}
lines.push("");
return lines.join("\n");
}
async function buildPushScriptPS(baseUrl, skillsDir) {
const lines = [
'$ErrorActionPreference = "Stop"',
"",
`$SkillsDir = "${skillsDir}"`,
`$BaseUrl = "${baseUrl}"`,
'$Filter = if ($args.Count -gt 0) { $args[0] } else { "" }',
'$TokenFile = Join-Path $HOME ".claude\\skills.here-token"',
"",
"# Get git author if available",
'$AuthorName = try { git config user.name 2>$null } catch { "" }',
'$AuthorEmail = try { git config user.email 2>$null } catch { "" }',
"",
"# Load or register token",
'$Token = ""',
"if (Test-Path $TokenFile) {",
" $Token = (Get-Content $TokenFile -Raw).Trim()",
"} elseif ($AuthorEmail) {",
' Write-Host "No token found. Registering with $AuthorEmail..."',
" try {",
" $body = @{ email = $AuthorEmail; name = $AuthorName } | ConvertTo-Json",
' $resp = Invoke-WebRequest -Uri "$BaseUrl/api/auth/register" -Method POST -ContentType "application/json" -Body $body',
" if ($resp.StatusCode -eq 201) {",
" $Token = ($resp.Content | ConvertFrom-Json).token",
" New-Item -ItemType Directory -Force -Path (Split-Path $TokenFile) | Out-Null",
" Set-Content -Path $TokenFile -Value $Token",
' Write-Host " Token saved to $TokenFile"',
" }",
" } catch {",
" $code = $_.Exception.Response.StatusCode.value__",
" if ($code -eq 409) {",
' Write-Host " Email already registered. Place your token in $TokenFile"',
" } else {",
' Write-Host " Registration failed: $_"',
" }",
' Write-Host " Continuing without token (unprotected skills only)..."',
" }",
"}",
"",
"if (-not (Test-Path $SkillsDir)) {",
' Write-Host "No skills directory found at $SkillsDir"',
" exit 1",
"}",
"",
'$headers = @{ "Content-Type" = "application/json" }',
'if ($Token) { $headers["Authorization"] = "Bearer $Token" }',
"",
"function Push-Skill($file) {",
" $slug = [IO.Path]::GetFileNameWithoutExtension($file)",
" $content = Get-Content $file -Raw",
"",
" # Inject author if available and not present",
' if ($AuthorEmail -and $content -notmatch "(?m)^author-email:") {',
' $inject = ""',
' if ($AuthorName) { $inject += "author: $AuthorName`n" }',
' $inject += "author-email: $AuthorEmail"',
' $content = $content -replace "^---$", "---`n$inject" -replace "^---`n", "---`n"',
" }",
"",
" $body = @{ content = $content } | ConvertTo-Json",
" try {",
' Invoke-WebRequest -Uri "$BaseUrl/api/skills/$slug" -Method PUT -Headers $headers -Body $body | Out-Null',
' Write-Host " ✓ $slug"',
" return $true",
" } catch {",
" $code = $_.Exception.Response.StatusCode.value__",
" if ($code -eq 403) {",
' Write-Host " ✗ $slug (permission denied)"',
" return $false",
" }",
" if ($code -eq 404) {",
" $postBody = @{ slug = $slug; content = $content } | ConvertTo-Json",
" try {",
' Invoke-WebRequest -Uri "$BaseUrl/api/skills" -Method POST -Headers $headers -Body $postBody | Out-Null',
' Write-Host " ✓ $slug"',
" return $true",
" } catch {",
" $postCode = $_.Exception.Response.StatusCode.value__",
" if ($postCode -eq 403) {",
' Write-Host " ✗ $slug (permission denied)"',
" return $false",
" }",
" }",
" }",
" }",
' Write-Host " ✓ $slug"',
" return $true",
"}",
"",
"$count = 0; $failed = 0",
"if ($Filter) {",
` $file = Join-Path $SkillsDir "$($Filter -replace '\\.md$','').md"`,
' if (-not (Test-Path $file)) { Write-Host "Skill not found: $file"; exit 1 }',
" if (Push-Skill $file) { $count++ } else { $failed++ }",
"} else {",
' Get-ChildItem -Path $SkillsDir -Filter "*.md" | ForEach-Object {',
" if (Push-Skill $_.FullName) { $count++ } else { $failed++ }",
" }",
"}",
"",
'Write-Host "Pushed $count skill(s) to $BaseUrl"',
'if ($failed -gt 0) { Write-Host "$failed skill(s) failed (permission denied)" }',
""
];
return lines.join("\n");
}
export { buildSyncScriptPS as a, buildSyncScript as b, buildPushScript as c, buildPushScriptPS as d, isPowerShell as i };

View File

@@ -1,71 +0,0 @@
import { l as listSkills } from './skills_COWfD5oy.mjs';
async function buildPushScript(baseUrl, skillsDir) {
const lines = [
"#!/usr/bin/env bash",
"set -euo pipefail",
"",
`SKILLS_DIR="${skillsDir}"`,
`BASE_URL="${baseUrl}"`,
"",
'if [ ! -d "$SKILLS_DIR" ]; then',
' echo "No skills directory found at $SKILLS_DIR"',
" exit 1",
"fi",
"",
"count=0",
'for file in "$SKILLS_DIR"/*.md; do',
' [ -f "$file" ] || continue',
' slug=$(basename "$file" .md)',
' content=$(cat "$file")',
"",
" # Try PUT (update), fallback to POST (create)",
' status=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills/$slug")',
"",
' if [ "$status" = "404" ]; then',
" curl -fsS -X POST \\",
' -H "Content-Type: application/json" \\',
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills" > /dev/null',
" fi",
"",
' echo " ✓ $slug"',
" count=$((count + 1))",
"done",
"",
'echo "Pushed $count skill(s) to $BASE_URL"',
""
];
return lines.join("\n");
}
async function buildSyncScript(baseUrl, skillsDir) {
const skills = await listSkills();
const lines = [
"#!/usr/bin/env bash",
"set -euo pipefail",
"",
`SKILLS_DIR="${skillsDir}"`,
'mkdir -p "$SKILLS_DIR"',
""
];
if (skills.length === 0) {
lines.push('echo "No skills available to sync."');
} else {
lines.push(`echo "Syncing ${skills.length} skill(s) from ${baseUrl}..."`);
lines.push("");
for (const skill of skills) {
const skillUrl = `${baseUrl}/${skill.slug}`;
lines.push(`curl -fsSL "${skillUrl}" -o "$SKILLS_DIR/${skill.slug}.md"`);
lines.push(`echo " ✓ ${skill.name}"`);
}
lines.push("");
lines.push('echo "Done! Skills synced to $SKILLS_DIR"');
}
lines.push("");
return lines.join("\n");
}
export { buildPushScript as a, buildSyncScript as b };

55
dist/server/chunks/tokens_CAzj9Aj8.mjs vendored Normal file
View File

@@ -0,0 +1,55 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
const TOKENS_FILE = path.resolve(process.env.TOKENS_FILE || "data/tokens.json");
async function readStore() {
try {
const raw = await fs.readFile(TOKENS_FILE, "utf-8");
return JSON.parse(raw);
} catch {
return {};
}
}
async function writeStore(store) {
const dir = path.dirname(TOKENS_FILE);
await fs.mkdir(dir, { recursive: true });
const tmp = TOKENS_FILE + ".tmp";
await fs.writeFile(tmp, JSON.stringify(store, null, 2), "utf-8");
await fs.rename(tmp, TOKENS_FILE);
}
function hashToken(token) {
return crypto.createHash("sha256").update(token).digest("hex");
}
async function generateToken(email, name) {
const store = await readStore();
if (store[email]) {
throw new Error("Email already registered");
}
const token = crypto.randomBytes(32).toString("hex");
store[email] = {
hash: hashToken(token),
name,
createdAt: (/* @__PURE__ */ new Date()).toISOString()
};
await writeStore(store);
return token;
}
async function verifyToken(email, token) {
if (!email || !token) return false;
const store = await readStore();
const entry = store[email];
if (!entry) return false;
return entry.hash === hashToken(token);
}
async function hasToken(email) {
const store = await readStore();
return Boolean(store[email]);
}
function extractBearerToken(request) {
const auth = request.headers.get("Authorization") || "";
const match = auth.match(/^Bearer\s+(\S+)$/i);
return match ? match[1] : "";
}
export { extractBearerToken as e, generateToken as g, hasToken as h, verifyToken as v };

60
dist/server/entry.mjs vendored
View File

@@ -1,36 +1,44 @@
import { renderers } from './renderers.mjs';
import { c as createExports, s as serverEntrypointModule } from './chunks/_@astrojs-ssr-adapter_DIu76Dvd.mjs';
import { manifest } from './manifest_BJPuFUv4.mjs';
import { c as createExports, s as serverEntrypointModule } from './chunks/_@astrojs-ssr-adapter_BeL8VyJ8.mjs';
import { manifest } from './manifest_Bz0Ba_R4.mjs';
const serverIslandMap = new Map();;
const _page0 = () => import('./pages/_image.astro.mjs');
const _page1 = () => import('./pages/api/skills/_slug_.astro.mjs');
const _page2 = () => import('./pages/api/skills.astro.mjs');
const _page3 = () => import('./pages/api/sync/project.astro.mjs');
const _page4 = () => import('./pages/api/sync.astro.mjs');
const _page5 = () => import('./pages/gi.astro.mjs');
const _page6 = () => import('./pages/gp.astro.mjs');
const _page7 = () => import('./pages/i.astro.mjs');
const _page8 = () => import('./pages/new.astro.mjs');
const _page9 = () => import('./pages/p.astro.mjs');
const _page10 = () => import('./pages/_slug_/edit.astro.mjs');
const _page11 = () => import('./pages/_slug_.astro.mjs');
const _page12 = () => import('./pages/index.astro.mjs');
const _page1 = () => import('./pages/api/auth/register.astro.mjs');
const _page2 = () => import('./pages/api/auth/verify.astro.mjs');
const _page3 = () => import('./pages/api/skills/_slug_.astro.mjs');
const _page4 = () => import('./pages/api/skills.astro.mjs');
const _page5 = () => import('./pages/api/sync/project.astro.mjs');
const _page6 = () => import('./pages/api/sync.astro.mjs');
const _page7 = () => import('./pages/gi.astro.mjs');
const _page8 = () => import('./pages/gp.astro.mjs');
const _page9 = () => import('./pages/i.astro.mjs');
const _page10 = () => import('./pages/new.astro.mjs');
const _page11 = () => import('./pages/p.astro.mjs');
const _page12 = () => import('./pages/_slug_/edit.astro.mjs');
const _page13 = () => import('./pages/_slug_/gi.astro.mjs');
const _page14 = () => import('./pages/_slug_/i.astro.mjs');
const _page15 = () => import('./pages/_slug_.astro.mjs');
const _page16 = () => import('./pages/index.astro.mjs');
const pageMap = new Map([
["node_modules/astro/dist/assets/endpoint/node.js", _page0],
["src/pages/api/skills/[slug].ts", _page1],
["src/pages/api/skills/index.ts", _page2],
["src/pages/api/sync/project.ts", _page3],
["src/pages/api/sync/index.ts", _page4],
["src/pages/gi.ts", _page5],
["src/pages/gp.ts", _page6],
["src/pages/i.ts", _page7],
["src/pages/new.astro", _page8],
["src/pages/p.ts", _page9],
["src/pages/[slug]/edit.astro", _page10],
["src/pages/[slug].astro", _page11],
["src/pages/index.astro", _page12]
["src/pages/api/auth/register.ts", _page1],
["src/pages/api/auth/verify.ts", _page2],
["src/pages/api/skills/[slug].ts", _page3],
["src/pages/api/skills/index.ts", _page4],
["src/pages/api/sync/project.ts", _page5],
["src/pages/api/sync/index.ts", _page6],
["src/pages/gi.ts", _page7],
["src/pages/gp.ts", _page8],
["src/pages/i.ts", _page9],
["src/pages/new.astro", _page10],
["src/pages/p.ts", _page11],
["src/pages/[slug]/edit.astro", _page12],
["src/pages/[slug]/gi.ts", _page13],
["src/pages/[slug]/i.ts", _page14],
["src/pages/[slug].astro", _page15],
["src/pages/index.astro", _page16]
]);
const _manifest = Object.assign(manifest, {

File diff suppressed because one or more lines are too long

101
dist/server/manifest_Bz0Ba_R4.mjs vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +1,2 @@
export { a as page } from '../chunks/node_WXNYuHqd.mjs';
export { a as page } from '../chunks/node_HH9e2ntY.mjs';
export { renderers } from '../renderers.mjs';

View File

@@ -1,45 +1,240 @@
import { e as createComponent, k as renderComponent, l as renderScript, r as renderTemplate, h as createAstro, m as maybeRenderHead, g as addAttribute, u as unescapeHTML } from '../chunks/astro/server_B-2LxKLH.mjs';
import { e as createAstro, f as createComponent, k as renderComponent, l as renderScript, r as renderTemplate, m as maybeRenderHead, h as addAttribute, u as unescapeHTML } from '../chunks/astro/server_CF97kUu8.mjs';
import 'piccolore';
import { _ as _export_sfc, $ as $$Base } from '../chunks/_plugin-vue_export-helper_B1lnwsE2.mjs';
import { useSSRContext, defineComponent, ref, mergeProps } from 'vue';
import { ssrRenderAttrs, ssrInterpolate } from 'vue/server-renderer';
import { g as getSkill } from '../chunks/skills_COWfD5oy.mjs';
import { _ as _export_sfc, $ as $$Base } from '../chunks/_plugin-vue_export-helper_CEgY73aA.mjs';
import { useSSRContext, defineComponent, ref, nextTick, mergeProps } from 'vue';
import { ssrRenderTeleport, ssrInterpolate, ssrRenderAttr, ssrIncludeBooleanAttr, ssrRenderAttrs } from 'vue/server-renderer';
import { g as getSkill, b as getForksOf } from '../chunks/skills_BacVQUiS.mjs';
import { h as hasToken } from '../chunks/tokens_CAzj9Aj8.mjs';
import { a as recordDownload, g as getStatsForSlug } from '../chunks/stats_CaDi9y9J.mjs';
import { marked } from 'marked';
/* empty css */
export { renderers } from '../renderers.mjs';
const _sfc_main$1 = /* @__PURE__ */ defineComponent({
__name: "EditGate",
props: {
slug: {},
authorEmail: {},
authorName: {},
authorHasToken: { type: Boolean }
},
setup(__props, { expose: __expose }) {
__expose();
const props = __props;
const showModal = ref(false);
const token = ref("");
const error = ref("");
const verifying = ref(false);
const tokenInput = ref();
async function handleClick() {
if (!props.authorEmail || !props.authorHasToken) {
window.location.href = `/${props.slug}/edit`;
return;
}
const saved = localStorage.getItem("skillshere-token") || "";
if (saved) {
try {
const res = await fetch("/api/auth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: props.authorEmail, token: saved })
});
if (res.ok) {
localStorage.setItem("skillshere-token", saved);
window.location.href = `/${props.slug}/edit`;
return;
}
} catch {
}
}
showModal.value = true;
error.value = "";
token.value = "";
nextTick(() => tokenInput.value?.focus());
}
async function verify() {
verifying.value = true;
error.value = "";
try {
const res = await fetch("/api/auth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: props.authorEmail, token: token.value })
});
if (!res.ok) {
const data = await res.json();
error.value = data.error || "Invalid token";
return;
}
localStorage.setItem("skillshere-token", token.value);
window.location.href = `/${props.slug}/edit`;
} catch {
error.value = "Could not verify token";
} finally {
verifying.value = false;
}
}
function forkSkill() {
showModal.value = false;
window.location.href = `/new?from=${encodeURIComponent(props.slug)}`;
}
const __returned__ = { props, showModal, token, error, verifying, tokenInput, handleClick, verify, forkSkill };
Object.defineProperty(__returned__, "__isScriptSetup", { enumerable: false, value: true });
return __returned__;
}
});
function _sfc_ssrRender$1(_ctx, _push, _parent, _attrs, $props, $setup, $data, $options) {
_push(`<!--[--><button class="inline-flex items-center gap-1.5 rounded-lg border border-white/[0.08] bg-surface-200 px-3.5 py-2 text-sm font-medium text-gray-300 hover:border-white/[0.15] hover:text-white transition-all"><svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"></path></svg> Edit </button>`);
ssrRenderTeleport(_push, (_push2) => {
if ($setup.showModal) {
_push2(`<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"><div class="w-full max-w-md rounded-2xl border border-white/[0.08] bg-[var(--color-surface-200)] p-6 shadow-2xl"><h3 class="text-lg font-semibold text-white mb-1">Author Verification</h3><p class="text-sm text-gray-500 mb-4"> This skill is owned by <strong class="text-gray-300">${ssrInterpolate($props.authorName || $props.authorEmail)}</strong>. Enter your token to edit. </p><form><input${ssrRenderAttr("value", $setup.token)} type="password" placeholder="Paste your author token..." class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 font-mono focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all">`);
if ($setup.error) {
_push2(`<p class="mt-2 text-sm text-red-400">${ssrInterpolate($setup.error)}</p>`);
} else {
_push2(`<!---->`);
}
_push2(`<div class="mt-4 flex items-center gap-3"><button type="submit"${ssrIncludeBooleanAttr($setup.verifying || !$setup.token) ? " disabled" : ""} class="inline-flex items-center gap-2 rounded-xl bg-[var(--color-accent-500)] px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-[var(--color-accent-500)]/20 hover:bg-[var(--color-accent-600)] disabled:opacity-50 active:scale-[0.97] transition-all">`);
if ($setup.verifying) {
_push2(`<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>`);
} else {
_push2(`<!---->`);
}
_push2(` ${ssrInterpolate($setup.verifying ? "Verifying..." : "Continue to Edit")}</button><button type="button" class="text-sm text-[var(--color-accent-400)] hover:text-[var(--color-accent-300)] transition-colors"> Fork instead </button><button type="button" class="ml-auto text-sm text-gray-600 hover:text-gray-300 transition-colors"> Cancel </button></div></form></div></div>`);
} else {
_push2(`<!---->`);
}
}, "body", false, _parent);
_push(`<!--]-->`);
}
const _sfc_setup$1 = _sfc_main$1.setup;
_sfc_main$1.setup = (props, ctx) => {
const ssrContext = useSSRContext();
(ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("src/components/EditGate.vue");
return _sfc_setup$1 ? _sfc_setup$1(props, ctx) : void 0;
};
const EditGate = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["ssrRender", _sfc_ssrRender$1]]);
const _sfc_main = /* @__PURE__ */ defineComponent({
__name: "DeleteButton",
props: {
slug: {}
slug: {},
authorEmail: {},
authorName: {},
authorHasToken: { type: Boolean }
},
setup(__props, { expose: __expose }) {
__expose();
const props = __props;
const deleting = ref(false);
async function handleDelete() {
if (!confirm(`Delete "${props.slug}"? This cannot be undone.`)) return;
const showModal = ref(false);
const token = ref("");
const error = ref("");
const tokenInput = ref();
async function handleClick() {
if (props.authorEmail && props.authorHasToken) {
const saved = localStorage.getItem("skillshere-token") || "";
if (saved) {
try {
const res = await fetch("/api/auth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: props.authorEmail, token: saved })
});
if (res.ok) {
doDelete(saved);
return;
}
} catch {
}
}
showModal.value = true;
error.value = "";
token.value = "";
nextTick(() => tokenInput.value?.focus());
} else {
doDelete("");
}
}
async function verifyAndDelete() {
error.value = "";
deleting.value = true;
try {
const res = await fetch(`/api/skills/${props.slug}`, { method: "DELETE" });
const verifyRes = await fetch("/api/auth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: props.authorEmail, token: token.value })
});
if (!verifyRes.ok) {
const data = await verifyRes.json();
error.value = data.error || "Invalid token";
deleting.value = false;
return;
}
} catch {
error.value = "Could not verify token";
deleting.value = false;
return;
}
localStorage.setItem("skillshere-token", token.value);
doDelete(token.value);
}
async function doDelete(authToken) {
if (!confirm(`Delete "${props.slug}"? This cannot be undone.`)) {
deleting.value = false;
return;
}
deleting.value = true;
error.value = "";
try {
const headers = {};
if (authToken) {
headers["Authorization"] = `Bearer ${authToken}`;
}
const res = await fetch(`/api/skills/${props.slug}`, { method: "DELETE", headers });
if (res.status === 403) {
const data = await res.json();
error.value = data.error || "Permission denied";
showModal.value = true;
deleting.value = false;
return;
}
if (!res.ok && res.status !== 204) {
throw new Error("Failed to delete");
const data = await res.json().catch(() => ({ error: "Failed to delete" }));
throw new Error(data.error || "Failed to delete");
}
window.location.href = "/";
} catch {
alert("Failed to delete skill.");
} catch (err) {
error.value = err instanceof Error ? err.message : "Failed to delete skill.";
deleting.value = false;
}
}
const __returned__ = { props, deleting, handleDelete };
const __returned__ = { props, deleting, showModal, token, error, tokenInput, handleClick, verifyAndDelete, doDelete };
Object.defineProperty(__returned__, "__isScriptSetup", { enumerable: false, value: true });
return __returned__;
}
});
function _sfc_ssrRender(_ctx, _push, _parent, _attrs, $props, $setup, $data, $options) {
_push(`<button${ssrRenderAttrs(mergeProps({
disabled: $setup.deleting,
class: "inline-flex items-center gap-1.5 rounded-lg border border-red-500/20 bg-red-500/5 px-3.5 py-2 text-sm font-medium text-red-400 hover:bg-red-500/10 hover:border-red-500/30 disabled:opacity-50 active:scale-[0.97] transition-all"
}, _attrs))}><svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"></path></svg> ${ssrInterpolate($setup.deleting ? "Deleting..." : "Delete")}</button>`);
_push(`<div${ssrRenderAttrs(mergeProps({ class: "inline-flex" }, _attrs))}><button${ssrIncludeBooleanAttr($setup.deleting) ? " disabled" : ""} class="inline-flex items-center gap-1.5 rounded-lg border border-red-500/20 bg-red-500/5 px-3.5 py-2 text-sm font-medium text-red-400 hover:bg-red-500/10 hover:border-red-500/30 disabled:opacity-50 active:scale-[0.97] transition-all"><svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"></path></svg> ${ssrInterpolate($setup.deleting ? "Deleting..." : "Delete")}</button>`);
ssrRenderTeleport(_push, (_push2) => {
if ($setup.showModal) {
_push2(`<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"><div class="w-full max-w-md rounded-2xl border border-white/[0.08] bg-[var(--color-surface-200)] p-6 shadow-2xl"><h3 class="text-lg font-semibold text-red-400 mb-1">Delete Skill</h3><p class="text-sm text-gray-500 mb-4"> This skill is owned by <strong class="text-gray-300">${ssrInterpolate($props.authorName || $props.authorEmail)}</strong>. Enter your token to delete it. </p><form><input${ssrRenderAttr("value", $setup.token)} type="password" placeholder="Paste your author token..." class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 font-mono focus:border-red-500/50 focus:outline-none focus:ring-1 focus:ring-red-500/20 transition-all">`);
if ($setup.error) {
_push2(`<p class="mt-2 text-sm text-red-400">${ssrInterpolate($setup.error)}</p>`);
} else {
_push2(`<!---->`);
}
_push2(`<div class="mt-4 flex items-center gap-3"><button type="submit"${ssrIncludeBooleanAttr($setup.deleting || !$setup.token) ? " disabled" : ""} class="inline-flex items-center gap-2 rounded-xl bg-red-500 px-5 py-2 text-sm font-semibold text-white hover:bg-red-600 disabled:opacity-50 active:scale-[0.97] transition-all">`);
if ($setup.deleting) {
_push2(`<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>`);
} else {
_push2(`<!---->`);
}
_push2(` ${ssrInterpolate($setup.deleting ? "Deleting..." : "Delete Permanently")}</button><button type="button" class="ml-auto text-sm text-gray-600 hover:text-gray-300 transition-colors"> Cancel </button></div></form></div></div>`);
} else {
_push2(`<!---->`);
}
}, "body", false, _parent);
_push(`</div>`);
}
const _sfc_setup = _sfc_main.setup;
_sfc_main.setup = (props, ctx) => {
@@ -49,7 +244,7 @@ _sfc_main.setup = (props, ctx) => {
};
const DeleteButton = /* @__PURE__ */ _export_sfc(_sfc_main, [["ssrRender", _sfc_ssrRender]]);
const $$Astro = createAstro();
const $$Astro = createAstro("https://skills.here.run.place");
const $$slug = createComponent(async ($$result, $$props, $$slots) => {
const Astro2 = $$result.createAstro($$Astro, $$props, $$slots);
Astro2.self = $$slug;
@@ -60,19 +255,26 @@ const $$slug = createComponent(async ($$result, $$props, $$slots) => {
}
const accept = Astro2.request.headers.get("accept") || "";
if (!accept.includes("text/html")) {
recordDownload(slug);
return new Response(skill.raw, {
headers: { "Content-Type": "text/markdown; charset=utf-8" }
});
}
const authorHasToken = skill["author-email"] ? await hasToken(skill["author-email"]) : false;
const forks = await getForksOf(slug);
const stats = await getStatsForSlug(slug);
const html = await marked(skill.content);
const installCmd = `curl -fsSL ${Astro2.url.origin}/${slug} -o .claude/skills/${slug}.md`;
return renderTemplate`${renderComponent($$result, "Base", $$Base, { "title": `${skill.name} \u2014 Skillit` }, { "default": async ($$result2) => renderTemplate` ${maybeRenderHead()}<div class="mb-8"> <a href="/" class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4"> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"></path> </svg>
const origin = Astro2.url.origin;
const cmds = {
unix: `curl -fsSL ${origin}/${slug}/i | bash`,
unixGlobal: `curl -fsSL ${origin}/${slug}/gi | bash`,
win: `irm ${origin}/${slug}/i | iex`,
winGlobal: `irm ${origin}/${slug}/gi | iex`
};
return renderTemplate`${renderComponent($$result, "Base", $$Base, { "title": `${skill.name} \u2014 Skills Here`, "data-astro-cid-yvbahnfj": true }, { "default": async ($$result2) => renderTemplate` ${maybeRenderHead()}<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4" data-astro-cid-yvbahnfj> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" data-astro-cid-yvbahnfj> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" data-astro-cid-yvbahnfj></path> </svg>
Back
</a> <div class="flex items-start justify-between gap-4"> <div> <h1 class="text-2xl font-bold tracking-tight text-white">${skill.name}</h1> ${skill.description && renderTemplate`<p class="text-gray-500 mt-1.5 leading-relaxed">${skill.description}</p>`} </div> <div class="flex items-center gap-2 shrink-0"> <a${addAttribute(`/${slug}/edit`, "href")} class="inline-flex items-center gap-1.5 rounded-lg border border-white/[0.08] bg-surface-200 px-3.5 py-2 text-sm font-medium text-gray-300 hover:border-white/[0.15] hover:text-white transition-all"> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"></path> </svg>
Edit
</a> ${renderComponent($$result2, "DeleteButton", DeleteButton, { "slug": slug, "client:load": true, "client:component-hydration": "load", "client:component-path": "/Users/alex/projects/skillit/src/components/DeleteButton.vue", "client:component-export": "default" })} </div> </div> </div> ${skill["allowed-tools"].length > 0 && renderTemplate`<div class="flex flex-wrap gap-1.5 mb-8"> ${skill["allowed-tools"].map((tool) => renderTemplate`<span class="rounded-md bg-white/[0.04] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400"> ${tool} </span>`)} </div>`} <div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6 mb-8 max-w-2xl space-y-4"> <h2 class="text-sm font-semibold text-white">Install this skill</h2> <p class="text-xs text-gray-500 leading-relaxed">Run this in your project root. The skill file will be saved to <code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded">.claude/skills/${slug}.md</code> and Claude Code will load it automatically.</p> <div class="flex items-center gap-3 rounded-xl bg-surface-50 border border-white/[0.06] px-4 py-3"> <code class="flex-1 text-xs font-mono text-gray-500 select-all truncate">${installCmd}</code> <button id="copy-install" class="shrink-0 rounded-md bg-white/[0.06] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] transition-all">
Copy
</button> </div> ${skill["allowed-tools"].length > 0 && renderTemplate`<p class="text-xs text-gray-600 leading-relaxed">This skill uses: ${skill["allowed-tools"].join(", ")}. Claude will have access to these tools when this skill is active.</p>`} </div> <article class="skill-prose rounded-2xl border border-white/[0.06] bg-surface-100 p-8">${unescapeHTML(html)}</article> ` })} ${renderScript($$result, "/Users/alex/projects/skillit/src/pages/[slug].astro?astro&type=script&index=0&lang.ts")}`;
</a> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem;" data-astro-cid-yvbahnfj> <div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6" style="min-width: 0;" data-astro-cid-yvbahnfj> <h1 class="text-2xl font-bold tracking-tight text-white mb-1" data-astro-cid-yvbahnfj>${skill.name}</h1> ${skill.description && renderTemplate`<p class="text-gray-500 leading-relaxed mb-3" data-astro-cid-yvbahnfj>${skill.description}</p>`} ${skill["allowed-tools"].length > 0 && renderTemplate`<div class="flex flex-wrap gap-1.5" data-astro-cid-yvbahnfj> ${skill["allowed-tools"].map((tool) => renderTemplate`<span class="rounded-md bg-white/[0.04] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400" data-astro-cid-yvbahnfj> ${tool} </span>`)} </div>`} ${skill.tags.length > 0 && renderTemplate`<div class="flex flex-wrap gap-1.5 mt-3" data-astro-cid-yvbahnfj> ${skill.tags.map((tag) => renderTemplate`<span class="rounded-full bg-[var(--color-accent-500)]/10 px-2.5 py-0.5 text-xs font-medium text-[var(--color-accent-400)]" data-astro-cid-yvbahnfj> ${tag} </span>`)} </div>`} ${skill.author && renderTemplate`<p class="text-xs text-gray-600 mt-3" data-astro-cid-yvbahnfj>by ${skill.author}</p>`} ${skill["fork-of"] && renderTemplate`<p class="text-xs text-gray-600 mt-1" data-astro-cid-yvbahnfj>forked from <a${addAttribute(`/${skill["fork-of"]}`, "href")} class="text-[var(--color-accent-500)] hover:text-[var(--color-accent-400)] transition-colors" data-astro-cid-yvbahnfj>${skill["fork-of"]}</a></p>`} ${forks.length > 0 && renderTemplate`<details class="mt-3" data-astro-cid-yvbahnfj> <summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-300 transition-colors select-none" data-astro-cid-yvbahnfj> ${forks.length} fork${forks.length !== 1 ? "s" : ""} </summary> <ul class="mt-1.5 space-y-1 pl-3 border-l border-white/[0.06]" data-astro-cid-yvbahnfj> ${forks.map((f) => renderTemplate`<li data-astro-cid-yvbahnfj> <a${addAttribute(`/${f.slug}`, "href")} class="text-xs text-[var(--color-accent-500)] hover:text-[var(--color-accent-400)] transition-colors" data-astro-cid-yvbahnfj> ${f.name} ${f.author && renderTemplate`<span class="text-gray-600" data-astro-cid-yvbahnfj> by ${f.author}</span>`} </a> </li>`)} </ul> </details>`} <div class="flex flex-wrap items-center gap-4 mt-4 pt-3 border-t border-white/[0.06]" data-astro-cid-yvbahnfj> <span class="inline-flex items-center gap-1.5 text-xs text-gray-500" data-astro-cid-yvbahnfj> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" data-astro-cid-yvbahnfj> <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" data-astro-cid-yvbahnfj></path> </svg> ${stats.downloads} download${stats.downloads !== 1 ? "s" : ""} </span> <span class="inline-flex items-center gap-1.5 text-xs text-gray-500" data-astro-cid-yvbahnfj> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" data-astro-cid-yvbahnfj> <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" data-astro-cid-yvbahnfj></path> </svg> ${stats.pushes} push${stats.pushes !== 1 ? "es" : ""} </span> ${stats.lastPushedAt && renderTemplate`<span class="text-xs text-gray-600" data-astro-cid-yvbahnfj>
Last updated ${new Date(stats.lastPushedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} </span>`} </div> </div> <div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6 space-y-4" style="min-width: 0;" data-astro-cid-yvbahnfj> <div class="flex items-center justify-between" data-astro-cid-yvbahnfj> <h2 class="text-sm font-semibold text-white" data-astro-cid-yvbahnfj>Install this skill</h2> <div class="flex rounded-lg border border-white/[0.06] overflow-hidden" id="os-tabs" data-astro-cid-yvbahnfj> <button data-os="unix" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all" data-astro-cid-yvbahnfj>macOS / Linux</button> <button data-os="win" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all" data-astro-cid-yvbahnfj>Windows</button> </div> </div> <p class="text-xs text-gray-500 leading-relaxed" data-astro-cid-yvbahnfj>Run in your project root to add this skill.</p> <div class="flex items-center gap-3 rounded-xl bg-surface-50 border border-white/[0.06] px-4 py-3" data-astro-cid-yvbahnfj> <code data-cmd="unix" class="flex-1 text-xs font-mono text-gray-500 select-all truncate" data-astro-cid-yvbahnfj>${cmds.unix}</code> <code data-cmd="win" class="flex-1 text-xs font-mono text-gray-500 select-all truncate hidden" data-astro-cid-yvbahnfj>${cmds.win}</code> <button data-copy class="shrink-0 rounded-md bg-white/[0.06] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] transition-all" data-astro-cid-yvbahnfj>Copy</button> </div> <details class="group" data-astro-cid-yvbahnfj> <summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-400 transition-colors" data-astro-cid-yvbahnfj>More options</summary> <div class="mt-3 space-y-3 text-xs text-gray-500" data-astro-cid-yvbahnfj> <div data-astro-cid-yvbahnfj> <p class="mb-1.5" data-astro-cid-yvbahnfj>Install globally (available in all projects):</p> <div class="flex items-center gap-3 rounded-lg bg-surface-50 border border-white/[0.06] px-3 py-2" data-astro-cid-yvbahnfj> <code data-cmd="unix" class="flex-1 font-mono text-gray-500 select-all truncate" data-astro-cid-yvbahnfj>${cmds.unixGlobal}</code> <code data-cmd="win" class="flex-1 font-mono text-gray-500 select-all truncate hidden" data-astro-cid-yvbahnfj>${cmds.winGlobal}</code> <button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all" data-astro-cid-yvbahnfj>Copy</button> </div> </div> </div> </details> </div> </div> <div class="relative rounded-2xl border border-white/[0.06] bg-surface-100 p-8" data-astro-cid-yvbahnfj> <div class="absolute top-4 right-4 flex items-center gap-2" data-astro-cid-yvbahnfj> ${renderComponent($$result2, "EditGate", EditGate, { "slug": slug, "authorEmail": skill["author-email"], "authorName": skill.author, "authorHasToken": authorHasToken, "client:load": true, "client:component-hydration": "load", "client:component-path": "/Users/alex/projects/skillit/src/components/EditGate.vue", "client:component-export": "default", "data-astro-cid-yvbahnfj": true })} ${renderComponent($$result2, "DeleteButton", DeleteButton, { "slug": slug, "authorEmail": skill["author-email"], "authorName": skill.author, "authorHasToken": authorHasToken, "client:load": true, "client:component-hydration": "load", "client:component-path": "/Users/alex/projects/skillit/src/components/DeleteButton.vue", "client:component-export": "default", "data-astro-cid-yvbahnfj": true })} </div> <article class="skill-prose" data-astro-cid-yvbahnfj>${unescapeHTML(html)}</article> </div> ` })} ${renderScript($$result, "/Users/alex/projects/skillit/src/pages/[slug].astro?astro&type=script&index=0&lang.ts")}`;
}, "/Users/alex/projects/skillit/src/pages/[slug].astro", void 0);
const $$file = "/Users/alex/projects/skillit/src/pages/[slug].astro";

View File

@@ -1,11 +1,11 @@
import { e as createComponent, k as renderComponent, r as renderTemplate, h as createAstro, m as maybeRenderHead, g as addAttribute } from '../../chunks/astro/server_B-2LxKLH.mjs';
import { e as createAstro, f as createComponent, k as renderComponent, r as renderTemplate, m as maybeRenderHead, h as addAttribute } from '../../chunks/astro/server_CF97kUu8.mjs';
import 'piccolore';
import { $ as $$Base } from '../../chunks/_plugin-vue_export-helper_B1lnwsE2.mjs';
import { g as getAvailableTools, a as getAvailableModels, S as SkillEditor } from '../../chunks/models_DPfuEi7q.mjs';
import { g as getSkill } from '../../chunks/skills_COWfD5oy.mjs';
import { $ as $$Base } from '../../chunks/_plugin-vue_export-helper_CEgY73aA.mjs';
import { g as getAvailableTools, a as getAvailableModels, S as SkillEditor } from '../../chunks/models_BK7lP4G3.mjs';
import { g as getSkill, a as getAllTags } from '../../chunks/skills_BacVQUiS.mjs';
export { renderers } from '../../renderers.mjs';
const $$Astro = createAstro();
const $$Astro = createAstro("https://skills.here.run.place");
const $$Edit = createComponent(async ($$result, $$props, $$slots) => {
const Astro2 = $$result.createAstro($$Astro, $$props, $$slots);
Astro2.self = $$Edit;
@@ -18,8 +18,9 @@ const $$Edit = createComponent(async ($$result, $$props, $$slots) => {
const availableModels = await getAvailableModels();
const allowedTools = skill["allowed-tools"].join(", ");
const hooksJson = skill.hooks ? JSON.stringify(skill.hooks, null, 2) : "";
return renderTemplate`${renderComponent($$result, "Base", $$Base, { "title": `Edit ${skill.name} \u2014 Skillit` }, { "default": async ($$result2) => renderTemplate` ${maybeRenderHead()}<a${addAttribute(`/${slug}`, "href")} class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4"> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"></path> </svg>
Back to ${skill.name} </a> <h1 class="text-2xl font-bold tracking-tight text-white mb-2">Edit Skill</h1> <p class="text-sm text-gray-500 mb-8">Editing <strong class="text-gray-400">${skill.name}</strong>. Users who already installed this skill will get the updated version on their next sync.</p> ${renderComponent($$result2, "SkillEditor", SkillEditor, { "mode": "edit", "slug": slug, "initialName": skill.name, "initialDescription": skill.description, "initialAllowedTools": allowedTools, "initialArgumentHint": skill["argument-hint"], "initialModel": skill.model, "initialUserInvocable": skill["user-invocable"], "initialDisableModelInvocation": skill["disable-model-invocation"], "initialContext": skill.context, "initialAgent": skill.agent, "initialHooks": hooksJson, "initialBody": skill.content, ":availableTools": availableTools, ":availableModels": availableModels, "client:load": true, "client:component-hydration": "load", "client:component-path": "/Users/alex/projects/skillit/src/components/SkillEditor.vue", "client:component-export": "default" })} ` })}`;
const availableTags = await getAllTags();
return renderTemplate`${renderComponent($$result, "Base", $$Base, { "title": `Edit ${skill.name} \u2014 Skills Here` }, { "default": async ($$result2) => renderTemplate` ${maybeRenderHead()}<a${addAttribute(`/${slug}`, "href")} class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4"> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"></path> </svg>
Back to ${skill.name} </a> <h1 class="text-2xl font-bold tracking-tight text-white mb-2">Edit Skill</h1> <p class="text-sm text-gray-500 mb-8">Editing <strong class="text-gray-400">${skill.name}</strong>. Users who already installed this skill will get the updated version on their next sync.</p> ${renderComponent($$result2, "SkillEditor", SkillEditor, { "mode": "edit", "slug": slug, "initialName": skill.name, "initialDescription": skill.description, "initialAllowedTools": allowedTools, "initialArgumentHint": skill["argument-hint"], "initialModel": skill.model, "initialUserInvocable": skill["user-invocable"], "initialDisableModelInvocation": skill["disable-model-invocation"], "initialContext": skill.context, "initialAgent": skill.agent, "initialHooks": hooksJson, "initialBody": skill.content, "initialAuthor": skill.author, "initialAuthorEmail": skill["author-email"], "initialTags": skill.tags.join(", "), ":availableTools": availableTools, ":availableModels": availableModels, "availableTags": availableTags.join(","), "client:load": true, "client:component-hydration": "load", "client:component-path": "/Users/alex/projects/skillit/src/components/SkillEditor.vue", "client:component-export": "default" })} ` })}`;
}, "/Users/alex/projects/skillit/src/pages/[slug]/edit.astro", void 0);
const $$file = "/Users/alex/projects/skillit/src/pages/[slug]/edit.astro";

40
dist/server/pages/_slug_/gi.astro.mjs vendored Normal file
View File

@@ -0,0 +1,40 @@
import { g as getSkill } from '../../chunks/skills_BacVQUiS.mjs';
import { i as isPowerShell } from '../../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../../renderers.mjs';
const GET = async ({ params, url, request }) => {
const { slug } = params;
const skill = await getSkill(slug);
if (!skill) {
return new Response("Skill not found", { status: 404 });
}
const origin = url.origin;
const ps = isPowerShell(request);
const script = ps ? [
'$ErrorActionPreference = "Stop"',
'$Dir = Join-Path $HOME ".claude\\skills"',
"New-Item -ItemType Directory -Force -Path $Dir | Out-Null",
`Invoke-WebRequest -Uri "${origin}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${skill.name} globally to $Dir\\${slug}.md"`,
""
].join("\n") : [
"#!/usr/bin/env bash",
"set -euo pipefail",
"mkdir -p ~/.claude/skills",
`curl -fsSL "${origin}/${slug}" -o ~/.claude/skills/${slug}.md`,
`echo "✓ Installed ${skill.name} globally to ~/.claude/skills/${slug}.md"`,
""
].join("\n");
return new Response(script, {
headers: { "Content-Type": "text/plain; charset=utf-8" }
});
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
GET
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

40
dist/server/pages/_slug_/i.astro.mjs vendored Normal file
View File

@@ -0,0 +1,40 @@
import { g as getSkill } from '../../chunks/skills_BacVQUiS.mjs';
import { i as isPowerShell } from '../../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../../renderers.mjs';
const GET = async ({ params, url, request }) => {
const { slug } = params;
const skill = await getSkill(slug);
if (!skill) {
return new Response("Skill not found", { status: 404 });
}
const origin = url.origin;
const ps = isPowerShell(request);
const script = ps ? [
'$ErrorActionPreference = "Stop"',
'$Dir = ".claude\\skills"',
"New-Item -ItemType Directory -Force -Path $Dir | Out-Null",
`Invoke-WebRequest -Uri "${origin}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${skill.name} to $Dir\\${slug}.md"`,
""
].join("\n") : [
"#!/usr/bin/env bash",
"set -euo pipefail",
"mkdir -p .claude/skills",
`curl -fsSL "${origin}/${slug}" -o ".claude/skills/${slug}.md"`,
`echo "✓ Installed ${skill.name} to .claude/skills/${slug}.md"`,
""
].join("\n");
return new Response(script, {
headers: { "Content-Type": "text/plain; charset=utf-8" }
});
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
GET
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -0,0 +1,55 @@
import { h as hasToken, g as generateToken } from '../../../chunks/tokens_CAzj9Aj8.mjs';
export { renderers } from '../../../renderers.mjs';
const POST = async ({ request }) => {
let body;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
const { email, name } = body;
if (!email) {
return new Response(JSON.stringify({ error: "email is required" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
if (await hasToken(email)) {
return new Response(JSON.stringify({ error: "Email already registered" }), {
status: 409,
headers: { "Content-Type": "application/json" }
});
}
try {
const token = await generateToken(email, name || "");
return new Response(JSON.stringify({ token }), {
status: 201,
headers: { "Content-Type": "application/json" }
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
if (message.includes("already registered")) {
return new Response(JSON.stringify({ error: message }), {
status: 409,
headers: { "Content-Type": "application/json" }
});
}
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
}
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
POST
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -0,0 +1,40 @@
import { v as verifyToken } from '../../../chunks/tokens_CAzj9Aj8.mjs';
export { renderers } from '../../../renderers.mjs';
const POST = async ({ request }) => {
let body;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
const { email, token } = body;
if (!email || !token) {
return new Response(JSON.stringify({ error: "email and token are required" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
const valid = await verifyToken(email, token);
if (!valid) {
return new Response(JSON.stringify({ error: "Invalid token" }), {
status: 403,
headers: { "Content-Type": "application/json" }
});
}
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json" }
});
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
POST
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,4 +1,7 @@
import { l as listSkills, i as isValidSlug, c as createSkill } from '../../chunks/skills_COWfD5oy.mjs';
import matter from 'gray-matter';
import { l as listSkills, i as isValidSlug, c as createSkill } from '../../chunks/skills_BacVQUiS.mjs';
import { h as hasToken, e as extractBearerToken, v as verifyToken } from '../../chunks/tokens_CAzj9Aj8.mjs';
import { r as recordPush } from '../../chunks/stats_CaDi9y9J.mjs';
export { renderers } from '../../renderers.mjs';
const GET = async () => {
@@ -30,8 +33,21 @@ const POST = async ({ request }) => {
headers: { "Content-Type": "application/json" }
});
}
const parsed = matter(content);
const authorEmail = parsed.data["author-email"] || "";
if (authorEmail && await hasToken(authorEmail)) {
const token = extractBearerToken(request);
const valid = await verifyToken(authorEmail, token);
if (!valid) {
return new Response(JSON.stringify({ error: "Valid token required to create a skill with author-email. Register first via POST /api/auth/register." }), {
status: 403,
headers: { "Content-Type": "application/json" }
});
}
}
try {
const skill = await createSkill(slug, content);
recordPush(slug);
return new Response(JSON.stringify(skill), {
status: 201,
headers: { "Content-Type": "application/json" }

View File

@@ -1,4 +1,7 @@
import { d as deleteSkill, g as getSkill, u as updateSkill } from '../../../chunks/skills_COWfD5oy.mjs';
import 'gray-matter';
import { g as getSkill, d as deleteSkill, u as updateSkill } from '../../../chunks/skills_BacVQUiS.mjs';
import { h as hasToken, e as extractBearerToken, v as verifyToken } from '../../../chunks/tokens_CAzj9Aj8.mjs';
import { r as recordPush } from '../../../chunks/stats_CaDi9y9J.mjs';
export { renderers } from '../../../renderers.mjs';
const GET = async ({ params }) => {
@@ -27,36 +30,59 @@ const PUT = async ({ params, request }) => {
});
}
try {
const existing = await getSkill(params.slug);
if (!existing) {
return new Response(JSON.stringify({ error: "Skill not found" }), {
status: 404,
headers: { "Content-Type": "application/json" }
});
}
if (existing["author-email"] && await hasToken(existing["author-email"])) {
const token = extractBearerToken(request);
const valid = await verifyToken(existing["author-email"], token);
if (!valid) {
return new Response(JSON.stringify({ error: `Only ${existing.author || existing["author-email"]} can update this skill. Provide a valid token via Authorization: Bearer header.` }), {
status: 403,
headers: { "Content-Type": "application/json" }
});
}
}
const skill = await updateSkill(params.slug, body.content);
recordPush(params.slug);
return new Response(JSON.stringify(skill), {
headers: { "Content-Type": "application/json" }
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
if (message.includes("not found")) {
return new Response(JSON.stringify({ error: message }), {
status: 404,
headers: { "Content-Type": "application/json" }
});
}
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
}
};
const DELETE = async ({ params }) => {
const DELETE = async ({ params, request }) => {
try {
await deleteSkill(params.slug);
return new Response(null, { status: 204 });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
if (message.includes("not found")) {
return new Response(JSON.stringify({ error: message }), {
const existing = await getSkill(params.slug);
if (!existing) {
return new Response(JSON.stringify({ error: "Skill not found" }), {
status: 404,
headers: { "Content-Type": "application/json" }
});
}
if (existing["author-email"] && await hasToken(existing["author-email"])) {
const token = extractBearerToken(request);
const valid = await verifyToken(existing["author-email"], token);
if (!valid) {
return new Response(JSON.stringify({ error: `Only ${existing.author || existing["author-email"]} can delete this skill. Provide a valid token via Authorization: Bearer header.` }), {
status: 403,
headers: { "Content-Type": "application/json" }
});
}
}
await deleteSkill(params.slug);
return new Response(null, { status: 204 });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { "Content-Type": "application/json" }

View File

@@ -1,4 +1,4 @@
import { b as buildSyncScript } from '../../chunks/sync_B_Og9xl3.mjs';
import { b as buildSyncScript } from '../../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../../renderers.mjs';
const GET = async ({ url }) => {

View File

@@ -1,4 +1,4 @@
import { b as buildSyncScript } from '../../../chunks/sync_B_Og9xl3.mjs';
import { b as buildSyncScript } from '../../../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../../../renderers.mjs';
const GET = async ({ url }) => {

View File

@@ -1,8 +1,9 @@
import { b as buildSyncScript } from '../chunks/sync_B_Og9xl3.mjs';
import { i as isPowerShell, a as buildSyncScriptPS, b as buildSyncScript } from '../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../renderers.mjs';
const GET = async ({ url }) => {
const script = await buildSyncScript(url.origin, "$HOME/.claude/skills");
const GET = async ({ url, request }) => {
const ps = isPowerShell(request);
const script = ps ? await buildSyncScriptPS(url.origin, "$HOME\\.claude\\skills") : await buildSyncScript(url.origin, "$HOME/.claude/skills");
return new Response(script, {
headers: { "Content-Type": "text/plain; charset=utf-8" }
});

View File

@@ -1,4 +1,4 @@
import { a as buildPushScript } from '../chunks/sync_B_Og9xl3.mjs';
import { c as buildPushScript } from '../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../renderers.mjs';
const GET = async ({ url }) => {

View File

@@ -1,4 +1,4 @@
import { b as buildSyncScript } from '../chunks/sync_B_Og9xl3.mjs';
import { b as buildSyncScript } from '../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../renderers.mjs';
const GET = async ({ url }) => {

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +1,25 @@
import { e as createComponent, k as renderComponent, r as renderTemplate, m as maybeRenderHead } from '../chunks/astro/server_B-2LxKLH.mjs';
import { e as createAstro, f as createComponent, k as renderComponent, r as renderTemplate, m as maybeRenderHead, h as addAttribute } from '../chunks/astro/server_CF97kUu8.mjs';
import 'piccolore';
import { $ as $$Base } from '../chunks/_plugin-vue_export-helper_B1lnwsE2.mjs';
import { g as getAvailableTools, a as getAvailableModels, S as SkillEditor } from '../chunks/models_DPfuEi7q.mjs';
import { $ as $$Base } from '../chunks/_plugin-vue_export-helper_CEgY73aA.mjs';
import { g as getAvailableTools, a as getAvailableModels, S as SkillEditor } from '../chunks/models_BK7lP4G3.mjs';
import { a as getAllTags, g as getSkill } from '../chunks/skills_BacVQUiS.mjs';
export { renderers } from '../renderers.mjs';
const $$Astro = createAstro("https://skills.here.run.place");
const $$New = createComponent(async ($$result, $$props, $$slots) => {
const Astro2 = $$result.createAstro($$Astro, $$props, $$slots);
Astro2.self = $$New;
const availableTools = await getAvailableTools();
const availableModels = await getAvailableModels();
return renderTemplate`${renderComponent($$result, "Base", $$Base, { "title": "New Skill \u2014 Skillit" }, { "default": async ($$result2) => renderTemplate` ${maybeRenderHead()}<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4"> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"></path> </svg>
Back
</a> <h1 class="text-2xl font-bold tracking-tight text-white mb-2">New Skill</h1> <p class="text-sm text-gray-500 mb-8 max-w-xl">Write a prompt in Markdown that tells Claude how to behave. The <strong class="text-gray-400">body</strong> is the instruction Claude receives. Use <strong class="text-gray-400">Allowed Tools</strong> to restrict which tools the skill can use.</p> ${renderComponent($$result2, "SkillEditor", SkillEditor, { "mode": "create", ":availableTools": availableTools, ":availableModels": availableModels, "client:load": true, "client:component-hydration": "load", "client:component-path": "/Users/alex/projects/skillit/src/components/SkillEditor.vue", "client:component-export": "default" })} ` })}`;
const availableTags = await getAllTags();
const fromSlug = Astro2.url.searchParams.get("from");
let forkSource = null;
if (fromSlug) {
forkSource = await getSkill(fromSlug);
}
const isFork = Boolean(forkSource);
const title = isFork ? `Fork ${forkSource.name} \u2014 Skills Here` : "New Skill \u2014 Skills Here";
return renderTemplate`${renderComponent($$result, "Base", $$Base, { "title": title }, { "default": async ($$result2) => renderTemplate` ${maybeRenderHead()}<a${addAttribute(isFork ? `/${fromSlug}` : "/", "href")} class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4"> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"></path> </svg> ${isFork ? `Back to ${forkSource.name}` : "Back"} </a> <h1 class="text-2xl font-bold tracking-tight text-white mb-2">${isFork ? "Fork Skill" : "New Skill"}</h1> ${isFork ? renderTemplate`<p class="text-sm text-gray-500 mb-8">Creating an independent copy of <strong class="text-gray-400">${forkSource.name}</strong>. Change the <strong class="text-gray-400">name</strong> to generate a new slug before saving.</p>` : renderTemplate`<p class="text-sm text-gray-500 mb-8 max-w-xl">Write a prompt in Markdown that tells Claude how to behave. The <strong class="text-gray-400">body</strong> is the instruction Claude receives. Use <strong class="text-gray-400">Allowed Tools</strong> to restrict which tools the skill can use.</p>`}${isFork ? renderTemplate`${renderComponent($$result2, "SkillEditor", SkillEditor, { "mode": "create", "forkOf": fromSlug, "initialName": forkSource.name, "initialDescription": forkSource.description, "initialAllowedTools": forkSource["allowed-tools"].join(", "), "initialArgumentHint": forkSource["argument-hint"], "initialModel": forkSource.model, "initialUserInvocable": forkSource["user-invocable"], "initialDisableModelInvocation": forkSource["disable-model-invocation"], "initialContext": forkSource.context, "initialAgent": forkSource.agent, "initialHooks": forkSource.hooks ? JSON.stringify(forkSource.hooks, null, 2) : "", "initialBody": forkSource.content, "initialTags": forkSource.tags.join(", "), ":availableTools": availableTools, ":availableModels": availableModels, "availableTags": availableTags.join(","), "client:load": true, "client:component-hydration": "load", "client:component-path": "/Users/alex/projects/skillit/src/components/SkillEditor.vue", "client:component-export": "default" })}` : renderTemplate`${renderComponent($$result2, "SkillEditor", SkillEditor, { "mode": "create", ":availableTools": availableTools, ":availableModels": availableModels, "availableTags": availableTags.join(","), "client:load": true, "client:component-hydration": "load", "client:component-path": "/Users/alex/projects/skillit/src/components/SkillEditor.vue", "client:component-export": "default" })}`}` })}`;
}, "/Users/alex/projects/skillit/src/pages/new.astro", void 0);
const $$file = "/Users/alex/projects/skillit/src/pages/new.astro";

View File

@@ -1,8 +1,9 @@
import { a as buildPushScript } from '../chunks/sync_B_Og9xl3.mjs';
import { i as isPowerShell, d as buildPushScriptPS, c as buildPushScript } from '../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../renderers.mjs';
const GET = async ({ url }) => {
const script = await buildPushScript(url.origin, ".claude/skills");
const GET = async ({ url, request }) => {
const ps = isPowerShell(request);
const script = ps ? await buildPushScriptPS(url.origin, ".claude\\skills") : await buildPushScript(url.origin, ".claude/skills");
return new Response(script, {
headers: { "Content-Type": "text/plain; charset=utf-8" }
});

1
package-lock.json generated
View File

@@ -7,7 +7,6 @@
"": {
"name": "skillit",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@astrojs/node": "^9.5.2",
"@astrojs/vue": "^5.1.4",

21
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,34 +1,162 @@
<template>
<button
@click="handleDelete"
:disabled="deleting"
class="inline-flex items-center gap-1.5 rounded-lg border border-red-500/20 bg-red-500/5 px-3.5 py-2 text-sm font-medium text-red-400 hover:bg-red-500/10 hover:border-red-500/30 disabled:opacity-50 active:scale-[0.97] transition-all"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
{{ deleting ? 'Deleting...' : 'Delete' }}
</button>
<div class="inline-flex">
<button
@click="handleClick"
:disabled="deleting"
class="inline-flex items-center gap-1.5 rounded-lg border border-red-500/20 bg-red-500/5 px-3.5 py-2 text-sm font-medium text-red-400 hover:bg-red-500/10 hover:border-red-500/30 disabled:opacity-50 active:scale-[0.97] transition-all"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
{{ deleting ? 'Deleting...' : 'Delete' }}
</button>
<!-- Token modal for protected skills -->
<Teleport to="body">
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showModal = false">
<div class="w-full max-w-md rounded-2xl border border-white/[0.08] bg-[var(--color-surface-200)] p-6 shadow-2xl">
<h3 class="text-lg font-semibold text-red-400 mb-1">Delete Skill</h3>
<p class="text-sm text-gray-500 mb-4">
This skill is owned by <strong class="text-gray-300">{{ authorName || authorEmail }}</strong>. Enter your token to delete it.
</p>
<form @submit.prevent="verifyAndDelete">
<input
ref="tokenInput"
v-model="token"
type="password"
placeholder="Paste your author token..."
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 font-mono focus:border-red-500/50 focus:outline-none focus:ring-1 focus:ring-red-500/20 transition-all"
/>
<p v-if="error" class="mt-2 text-sm text-red-400">{{ error }}</p>
<div class="mt-4 flex items-center gap-3">
<button
type="submit"
:disabled="deleting || !token"
class="inline-flex items-center gap-2 rounded-xl bg-red-500 px-5 py-2 text-sm font-semibold text-white hover:bg-red-600 disabled:opacity-50 active:scale-[0.97] transition-all"
>
<svg v-if="deleting" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{{ deleting ? 'Deleting...' : 'Delete Permanently' }}
</button>
<button
type="button"
@click="showModal = false"
class="ml-auto text-sm text-gray-600 hover:text-gray-300 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, nextTick } from 'vue';
const props = defineProps<{
slug: string;
authorEmail?: string;
authorName?: string;
authorHasToken?: boolean;
}>();
const props = defineProps<{ slug: string }>();
const deleting = ref(false);
const showModal = ref(false);
const token = ref('');
const error = ref('');
const tokenInput = ref<HTMLInputElement>();
async function handleDelete() {
if (!confirm(`Delete "${props.slug}"? This cannot be undone.`)) return;
async function handleClick() {
if (props.authorEmail && props.authorHasToken) {
// Try saved token first
const saved = localStorage.getItem('skillshere-token') || '';
if (saved) {
try {
const res = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: props.authorEmail, token: saved }),
});
if (res.ok) {
doDelete(saved);
return;
}
} catch { /* fall through to modal */ }
}
showModal.value = true;
error.value = '';
token.value = '';
nextTick(() => tokenInput.value?.focus());
} else {
doDelete('');
}
}
async function verifyAndDelete() {
// Verify token first
error.value = '';
deleting.value = true;
try {
const verifyRes = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: props.authorEmail, token: token.value }),
});
if (!verifyRes.ok) {
const data = await verifyRes.json();
error.value = data.error || 'Invalid token';
deleting.value = false;
return;
}
} catch {
error.value = 'Could not verify token';
deleting.value = false;
return;
}
localStorage.setItem('skillshere-token', token.value);
doDelete(token.value);
}
async function doDelete(authToken: string) {
if (!confirm(`Delete "${props.slug}"? This cannot be undone.`)) {
deleting.value = false;
return;
}
deleting.value = true;
error.value = '';
try {
const res = await fetch(`/api/skills/${props.slug}`, { method: 'DELETE' });
const headers: Record<string, string> = {};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const res = await fetch(`/api/skills/${props.slug}`, { method: 'DELETE', headers });
if (res.status === 403) {
const data = await res.json();
error.value = data.error || 'Permission denied';
showModal.value = true;
deleting.value = false;
return;
}
if (!res.ok && res.status !== 204) {
throw new Error('Failed to delete');
const data = await res.json().catch(() => ({ error: 'Failed to delete' }));
throw new Error(data.error || 'Failed to delete');
}
window.location.href = '/';
} catch {
alert('Failed to delete skill.');
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to delete skill.';
deleting.value = false;
}
}

143
src/components/EditGate.vue Normal file
View File

@@ -0,0 +1,143 @@
<template>
<!-- Edit button -->
<button
@click="handleClick"
class="inline-flex items-center gap-1.5 rounded-lg border border-white/[0.08] bg-surface-200 px-3.5 py-2 text-sm font-medium text-gray-300 hover:border-white/[0.15] hover:text-white transition-all"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
Edit
</button>
<!-- Modal backdrop -->
<Teleport to="body">
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showModal = false">
<div class="w-full max-w-md rounded-2xl border border-white/[0.08] bg-[var(--color-surface-200)] p-6 shadow-2xl">
<h3 class="text-lg font-semibold text-white mb-1">Author Verification</h3>
<p class="text-sm text-gray-500 mb-4">
This skill is owned by <strong class="text-gray-300">{{ authorName || authorEmail }}</strong>. Enter your token to edit.
</p>
<form @submit.prevent="verify">
<input
ref="tokenInput"
v-model="token"
type="password"
placeholder="Paste your author token..."
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 font-mono focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
/>
<p v-if="error" class="mt-2 text-sm text-red-400">{{ error }}</p>
<div class="mt-4 flex items-center gap-3">
<button
type="submit"
:disabled="verifying || !token"
class="inline-flex items-center gap-2 rounded-xl bg-[var(--color-accent-500)] px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-[var(--color-accent-500)]/20 hover:bg-[var(--color-accent-600)] disabled:opacity-50 active:scale-[0.97] transition-all"
>
<svg v-if="verifying" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{{ verifying ? 'Verifying...' : 'Continue to Edit' }}
</button>
<button
type="button"
@click="forkSkill"
class="text-sm text-[var(--color-accent-400)] hover:text-[var(--color-accent-300)] transition-colors"
>
Fork instead
</button>
<button
type="button"
@click="showModal = false"
class="ml-auto text-sm text-gray-600 hover:text-gray-300 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue';
const props = defineProps<{
slug: string;
authorEmail?: string;
authorName?: string;
authorHasToken?: boolean;
}>();
const showModal = ref(false);
const token = ref('');
const error = ref('');
const verifying = ref(false);
const tokenInput = ref<HTMLInputElement>();
async function handleClick() {
if (!props.authorEmail || !props.authorHasToken) {
window.location.href = `/${props.slug}/edit`;
return;
}
// Try saved token first
const saved = localStorage.getItem('skillshere-token') || '';
if (saved) {
try {
const res = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: props.authorEmail, token: saved }),
});
if (res.ok) {
localStorage.setItem('skillshere-token', saved);
window.location.href = `/${props.slug}/edit`;
return;
}
} catch { /* fall through to modal */ }
}
showModal.value = true;
error.value = '';
token.value = '';
nextTick(() => tokenInput.value?.focus());
}
async function verify() {
verifying.value = true;
error.value = '';
try {
const res = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: props.authorEmail, token: token.value }),
});
if (!res.ok) {
const data = await res.json();
error.value = data.error || 'Invalid token';
return;
}
// Store token for the editor to use
localStorage.setItem('skillshere-token', token.value);
window.location.href = `/${props.slug}/edit`;
} catch {
error.value = 'Could not verify token';
} finally {
verifying.value = false;
}
}
function forkSkill() {
showModal.value = false;
window.location.href = `/new?from=${encodeURIComponent(props.slug)}`;
}
</script>

View File

@@ -4,9 +4,16 @@ interface Props {
name: string;
description: string;
'allowed-tools': string[];
tags?: string[];
author?: string;
forkCount?: number;
downloads?: number;
pushes?: number;
lastPushedAt?: string | null;
}
const { slug, name, description, 'allowed-tools': allowedTools } = Astro.props;
const { slug, name, description, 'allowed-tools': allowedTools, tags = [], author, forkCount = 0, downloads = 0, pushes = 0, lastPushedAt } = Astro.props;
const updatedLabel = lastPushedAt ? new Date(lastPushedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : null;
const truncated = description.length > 120 ? description.slice(0, 120) + '...' : description;
---
@@ -22,9 +29,9 @@ const truncated = description.length > 120 ? description.slice(0, 120) + '...' :
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</div>
{truncated && <p class="text-sm text-gray-500 leading-relaxed mb-4">{truncated}</p>}
{truncated && <p class="text-sm text-gray-500 leading-relaxed mb-3">{truncated}</p>}
{allowedTools.length > 0 && (
<div class="flex flex-wrap gap-1.5">
<div class="flex flex-wrap gap-1.5 mb-3">
{allowedTools.map((tool) => (
<span class="rounded-md bg-white/[0.04] border border-white/[0.06] px-2 py-0.5 text-xs font-medium text-gray-400">
{tool}
@@ -32,5 +39,44 @@ const truncated = description.length > 120 ? description.slice(0, 120) + '...' :
))}
</div>
)}
{tags.length > 0 && (
<div class="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span class="rounded-full bg-[var(--color-accent-500)]/10 border border-[var(--color-accent-500)]/20 px-2.5 py-0.5 text-[11px] font-medium text-[var(--color-accent-400)]">
{tag}
</span>
))}
</div>
)}
<div class="flex items-center gap-3 mt-3">
{author && <p class="text-xs text-gray-600">by {author}</p>}
{forkCount > 0 && (
<span class="inline-flex items-center gap-1 text-xs text-gray-600">
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0-12.814a2.25 2.25 0 1 0 0-2.186m0 2.186a2.25 2.25 0 1 0 0 2.186" />
</svg>
{forkCount}
</span>
)}
{downloads > 0 && (
<span class="inline-flex items-center gap-1 text-xs text-gray-600">
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{downloads}
</span>
)}
{pushes > 0 && (
<span class="inline-flex items-center gap-1 text-xs text-gray-600">
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
</svg>
{pushes}
</span>
)}
{updatedLabel && (
<span class="text-[11px] text-gray-600">{updatedLabel}</span>
)}
</div>
</div>
</a>

View File

@@ -1,5 +1,35 @@
<template>
<form @submit.prevent="save" class="space-y-6">
<!-- Fork: author identity -->
<div v-if="isFork" class="rounded-xl border border-[var(--color-accent-500)]/20 bg-[var(--color-accent-500)]/5 p-4 space-y-3">
<p class="text-sm text-[var(--color-accent-400)]">Claim this fork as yours. It will stay open for editing until you push from CLI, which registers a token and locks it to you.</p>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Your Name</label>
<input
v-model="forkAuthorName"
type="text"
placeholder="Jane Doe"
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
/>
</div>
<div>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1">Your Email</label>
<input
v-model="forkAuthorEmail"
type="email"
placeholder="jane@example.com"
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
/>
</div>
</div>
</div>
<!-- Fork slug warning -->
<div v-if="isFork && slugMatchesOriginal" class="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
<p class="text-sm text-amber-400">Change the <strong>name</strong> to generate a different slug. You can't save a fork with the same slug as the original.</p>
</div>
<!-- Basic -->
<div class="grid gap-4 sm:grid-cols-2">
<div>
@@ -8,11 +38,18 @@
v-model="name"
type="text"
required
maxlength="64"
placeholder="My Awesome Skill"
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
:class="[
'w-full rounded-xl border px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-1 transition-all',
isFork && slugMatchesOriginal
? 'border-amber-500/30 bg-[var(--color-surface-100)] focus:border-amber-500/50 focus:ring-amber-500/20'
: 'border-white/[0.06] bg-[var(--color-surface-100)] focus:border-[var(--color-accent-500)]/50 focus:ring-[var(--color-accent-500)]/20'
]"
/>
<p class="mt-1.5 text-xs text-gray-600">
Slug: <code class="text-gray-500 font-mono">{{ computedSlug }}</code>
<p class="mt-1.5 text-xs text-gray-600 flex justify-between">
<span>Slug: <code :class="['font-mono', isFork && slugMatchesOriginal ? 'text-amber-500' : 'text-gray-500']">{{ computedSlug }}</code></span>
<span :class="name.length > 58 ? 'text-amber-500' : ''">{{ name.length }}/64</span>
</p>
</div>
<div>
@@ -20,12 +57,66 @@
<input
v-model="description"
type="text"
maxlength="200"
placeholder="Brief description of what this skill does"
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
/>
<p class="mt-1.5 text-xs text-gray-600 text-right" :class="description.length > 180 ? 'text-amber-500' : ''">{{ description.length }}/200</p>
</div>
</div>
<!-- Tags -->
<div class="relative">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Tags</label>
<div
class="flex flex-wrap items-center gap-1.5 rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-3 py-2 min-h-[42px] cursor-text focus-within:border-[var(--color-accent-500)]/50 focus-within:ring-1 focus-within:ring-[var(--color-accent-500)]/20 transition-all"
@click="tagInputEl?.focus()"
>
<span
v-for="(tag, i) in tags"
:key="tag"
class="inline-flex items-center gap-1 rounded-full bg-[var(--color-accent-500)]/15 border border-[var(--color-accent-500)]/25 pl-2.5 pr-1.5 py-0.5 text-xs font-medium text-[var(--color-accent-400)]"
>
{{ tag }}
<button type="button" @click.stop="removeTag(i)" class="rounded-full p-0.5 hover:bg-[var(--color-accent-500)]/30 transition-colors">
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</span>
<input
ref="tagInputEl"
v-model="tagQuery"
type="text"
placeholder="Add tag..."
class="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-gray-600 outline-none"
@keydown="onTagKeydown"
@focus="tagSuggestionsOpen = true"
@click="tagSuggestionsOpen = true"
@input="tagSuggestionsOpen = true"
@blur="onTagBlur"
/>
</div>
<!-- Suggestions dropdown -->
<div
v-if="tagSuggestionsOpen && tagSuggestions.length > 0"
class="absolute z-10 mt-1 w-full max-h-40 overflow-auto rounded-xl border border-white/[0.08] bg-[var(--color-surface-200)] shadow-xl"
>
<button
v-for="s in tagSuggestions"
:key="s"
type="button"
@mousedown.prevent="onTagSuggestionClick(s)"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors text-left"
>
<span v-if="isNewTag(s)" class="text-[var(--color-accent-500)] text-xs">+</span>
{{ s }}
<span v-if="isNewTag(s)" class="text-xs text-gray-600">(new)</span>
</button>
</div>
<p class="mt-1.5 text-xs text-gray-600">Type and press Enter or comma. Click suggestions to add.</p>
</div>
<!-- Allowed Tools -->
<div>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Allowed Tools</label>
@@ -125,7 +216,10 @@
<!-- Body + Preview -->
<div class="grid gap-4 lg:grid-cols-2">
<div>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Skill Body</label>
<label class="flex justify-between text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">
<span>Skill Body</span>
<span :class="bodyLines > 400 ? 'text-amber-500' : ''">{{ bodyLines }}/500 lines</span>
</label>
<textarea
v-model="body"
rows="20"
@@ -145,15 +239,17 @@
<div class="flex items-center gap-4 pt-2">
<button
type="submit"
:disabled="saving"
:disabled="saving || (isFork && slugMatchesOriginal)"
:title="isFork && slugMatchesOriginal ? 'Change the name to generate a different slug' : ''"
class="inline-flex items-center gap-2 rounded-xl bg-[var(--color-accent-500)] px-6 py-2.5 text-sm font-semibold text-white shadow-lg shadow-[var(--color-accent-500)]/20 hover:bg-[var(--color-accent-600)] hover:shadow-[var(--color-accent-500)]/30 disabled:opacity-50 active:scale-[0.97] transition-all"
>
<svg v-if="saving" class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{{ saving ? 'Saving...' : (mode === 'create' ? 'Create Skill' : 'Save Changes') }}
{{ saving ? 'Saving...' : (isFork ? 'Create Fork' : (mode === 'create' ? 'Create Skill' : 'Save Changes')) }}
</button>
<a href="/" class="text-sm text-gray-600 hover:text-gray-300 transition-colors">Cancel</a>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
</div>
@@ -167,6 +263,7 @@ import { marked } from 'marked';
const props = defineProps<{
mode: 'create' | 'edit';
slug?: string;
forkOf?: string;
initialName?: string;
initialDescription?: string;
initialAllowedTools?: string;
@@ -178,8 +275,12 @@ const props = defineProps<{
initialAgent?: string;
initialHooks?: string;
initialBody?: string;
initialAuthor?: string;
initialAuthorEmail?: string;
initialTags?: string;
availableTools?: string[];
availableModels?: Array<{ id: string; display_name: string }>;
availableTags?: string;
}>();
const AVAILABLE_TOOLS = props.availableTools ?? [
@@ -193,6 +294,8 @@ const AVAILABLE_MODELS = props.availableModels ?? [
{ id: 'claude-haiku-4-5-20251001', display_name: 'Claude Haiku 4.5' },
];
const isFork = computed(() => Boolean(props.forkOf));
const name = ref(props.initialName || '');
const description = ref(props.initialDescription || '');
const argumentHint = ref(props.initialArgumentHint || '');
@@ -202,10 +305,32 @@ const disableModelInvocation = ref(props.initialDisableModelInvocation ?? false)
const context = ref(props.initialContext || '');
const agent = ref(props.initialAgent || '');
const hooksJson = ref(props.initialHooks || '');
const tags = ref<string[]>(
props.initialTags
? props.initialTags.split(',').map(t => t.trim()).filter(Boolean)
: []
);
const tagQuery = ref('');
const tagSuggestionsOpen = ref(false);
const tagInputEl = ref<HTMLInputElement>();
const knownTags = props.availableTags
? props.availableTags.split(',').map(t => t.trim()).filter(Boolean)
: [];
const body = ref(props.initialBody || '');
const saving = ref(false);
const error = ref('');
// Fork author fields
const forkAuthorName = ref('');
const forkAuthorEmail = ref('');
// Load token from localStorage (set by EditGate modal)
const authorToken = ref(
typeof localStorage !== 'undefined'
? localStorage.getItem('skillshere-token') || ''
: ''
);
const selectedTools = ref(new Set<string>(
props.initialAllowedTools
? props.initialAllowedTools.split(',').map(t => t.trim()).filter(Boolean)
@@ -221,6 +346,52 @@ function toggleTool(tool: string) {
selectedTools.value = new Set(selectedTools.value);
}
const tagSuggestions = computed(() => {
const q = tagQuery.value.toLowerCase().trim();
const current = new Set(tags.value.map(t => t.toLowerCase()));
const matches = knownTags.filter(t => !current.has(t.toLowerCase()) && (!q || t.toLowerCase().includes(q)));
// If typing something new that's not in existing tags, offer to create it
if (q && !current.has(q) && !matches.some(m => m.toLowerCase() === q)) {
matches.push(q);
}
return matches;
});
const isNewTag = (tag: string) => !knownTags.some(t => t.toLowerCase() === tag.toLowerCase());
function addTag(tag: string) {
const normalized = tag.trim().toLowerCase();
if (normalized && !tags.value.some(t => t.toLowerCase() === normalized)) {
tags.value.push(normalized);
}
tagQuery.value = '';
tagInputEl.value?.focus();
}
function removeTag(idx: number) {
tags.value.splice(idx, 1);
}
function onTagKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
if (tagQuery.value.trim()) {
addTag(tagQuery.value);
}
} else if (e.key === 'Backspace' && !tagQuery.value && tags.value.length) {
tags.value.pop();
}
}
let tagBlurTimer: ReturnType<typeof setTimeout>;
function onTagBlur() {
tagBlurTimer = setTimeout(() => { tagSuggestionsOpen.value = false; }, 200);
}
function onTagSuggestionClick(tag: string) {
clearTimeout(tagBlurTimer);
addTag(tag);
}
const computedSlug = computed(() => {
if (props.mode === 'edit' && props.slug) return props.slug;
return name.value
@@ -230,6 +401,13 @@ const computedSlug = computed(() => {
.slice(0, 64) || 'my-skill';
});
const slugMatchesOriginal = computed(() => {
if (!props.forkOf) return false;
return computedSlug.value === props.forkOf;
});
const bodyLines = computed(() => body.value.split('\n').length);
let previewHtml = ref('');
let debounceTimer: ReturnType<typeof setTimeout>;
@@ -244,11 +422,20 @@ function buildContent(): string {
const tools = [...selectedTools.value];
const lines: string[] = ['---'];
// Official Claude Code skill frontmatter format (kebab-case, comma-separated tools)
lines.push(`name: ${name.value}`);
if (description.value) lines.push(`description: ${description.value}`);
if (isFork.value) {
// Fork: use the new author's info + track origin
if (forkAuthorName.value) lines.push(`author: ${forkAuthorName.value}`);
if (forkAuthorEmail.value) lines.push(`author-email: ${forkAuthorEmail.value}`);
lines.push(`fork-of: ${props.forkOf}`);
} else {
if (props.initialAuthor) lines.push(`author: ${props.initialAuthor}`);
if (props.initialAuthorEmail) lines.push(`author-email: ${props.initialAuthorEmail}`);
}
if (argumentHint.value) lines.push(`argument-hint: ${argumentHint.value}`);
if (tools.length > 0) lines.push(`allowed-tools: ${tools.join(', ')}`);
if (tags.value.length > 0) lines.push(`tags: ${tags.value.join(', ')}`);
if (model.value) lines.push(`model: ${model.value}`);
if (userInvocable.value === false) lines.push('user-invocable: false');
if (disableModelInvocation.value) lines.push('disable-model-invocation: true');
@@ -273,11 +460,18 @@ async function save() {
try {
const content = buildContent();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
// Edit mode — use token from EditGate (localStorage)
if (!isFork.value && authorToken.value) {
headers['Authorization'] = `Bearer ${authorToken.value}`;
}
// Fork mode — no token needed; skill is unprotected until CLI push registers one
if (props.mode === 'create') {
const res = await fetch('/api/skills', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers,
body: JSON.stringify({ slug: computedSlug.value, content }),
});
if (!res.ok) {
@@ -288,7 +482,7 @@ async function save() {
} else {
const res = await fetch(`/api/skills/${props.slug}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers,
body: JSON.stringify({ content }),
});
if (!res.ok) {

View File

@@ -1,33 +1,426 @@
<template>
<div class="mb-6 max-w-md">
<div class="relative">
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<input
v-model="query"
type="text"
placeholder="Search skills..."
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
/>
<div class="mb-6 space-y-4">
<!-- Filters row -->
<div class="flex flex-wrap items-end gap-3">
<!-- Text search -->
<div class="w-full sm:w-auto sm:flex-1 sm:min-w-[180px]">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-600 mb-1">Search</label>
<div class="relative">
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<input
v-model="query"
type="text"
placeholder="Name, description, tools..."
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
/>
</div>
</div>
<!-- Author filter (pills + autocomplete) -->
<div class="w-[calc(50%-6px)] sm:w-auto sm:flex-1 sm:min-w-[160px] relative">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-600 mb-1">Authors</label>
<div class="flex flex-wrap items-center gap-1.5 rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-3 py-2 min-h-[42px] focus-within:border-[var(--color-accent-500)]/50 focus-within:ring-1 focus-within:ring-[var(--color-accent-500)]/20 transition-all">
<span
v-for="a in selectedAuthors"
:key="a"
class="inline-flex items-center gap-1 rounded-full bg-[var(--color-accent-500)]/15 text-[var(--color-accent-400)] px-2.5 py-0.5 text-xs font-medium"
>
{{ a }}
<button @click="removeAuthor(a)" class="hover:text-white transition-colors">&times;</button>
</span>
<input
ref="authorInputEl"
v-model="authorQuery"
type="text"
:placeholder="selectedAuthors.length ? '' : 'Filter by author...'"
@focus="authorOpen = true"
@click="authorOpen = true"
@input="authorOpen = true"
@blur="onAuthorBlur"
@keydown.enter.prevent="onAuthorEnter"
@keydown.backspace="onAuthorBackspace"
class="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-gray-600 outline-none"
/>
</div>
<div
v-if="authorOpen && authorSuggestions.length > 0"
class="absolute z-20 mt-1 w-full max-h-48 overflow-y-auto rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] shadow-xl"
>
<button
v-for="a in authorSuggestions"
:key="a"
@mousedown.prevent="addAuthor(a)"
class="block w-full text-left px-4 py-2 text-sm text-gray-400 hover:bg-white/[0.06] hover:text-white transition-colors"
>
{{ a }}
</button>
</div>
</div>
<!-- Tag filter (pills + autocomplete) -->
<div class="w-[calc(50%-6px)] sm:w-auto sm:flex-1 sm:min-w-[160px] relative">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-600 mb-1">Tags</label>
<div class="flex flex-wrap items-center gap-1.5 rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-3 py-2 min-h-[42px] focus-within:border-[var(--color-accent-500)]/50 focus-within:ring-1 focus-within:ring-[var(--color-accent-500)]/20 transition-all">
<span
v-for="t in selectedTags"
:key="t"
class="inline-flex items-center gap-1 rounded-full bg-[var(--color-accent-500)]/15 text-[var(--color-accent-400)] px-2.5 py-0.5 text-xs font-medium"
>
{{ t }}
<button @click="removeTag(t)" class="hover:text-white transition-colors">&times;</button>
</span>
<input
ref="tagInputEl"
v-model="tagQuery"
type="text"
:placeholder="selectedTags.length ? '' : 'Filter by tag...'"
@focus="tagOpen = true"
@click="tagOpen = true"
@input="tagOpen = true"
@blur="onTagBlur"
@keydown.enter.prevent="onTagEnter"
@keydown.backspace="onTagBackspace"
class="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-gray-600 outline-none"
/>
</div>
<div
v-if="tagOpen && tagSuggestions.length > 0"
class="absolute z-20 mt-1 w-full max-h-48 overflow-y-auto rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] shadow-xl"
>
<button
v-for="t in tagSuggestions"
:key="t"
@mousedown.prevent="addTag(t)"
class="block w-full text-left px-4 py-2 text-sm text-gray-400 hover:bg-white/[0.06] hover:text-white transition-colors"
>
{{ t }}
</button>
</div>
</div>
<!-- Fork count filter -->
<div class="w-[calc(50%-6px)] sm:w-auto sm:min-w-[130px]">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-600 mb-1">Forks</label>
<select
v-model="forkFilter"
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-white focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all"
>
<option value="">Any</option>
<option value="1">1+ forks</option>
<option value="3">3+ forks</option>
<option value="5">5+ forks</option>
</select>
</div>
<!-- Reset -->
<button
v-if="hasActiveFilters"
@click="reset"
class="rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-2.5 text-sm text-gray-500 hover:text-white hover:bg-white/[0.06] transition-all"
>
Clear
</button>
<!-- View toggle -->
<div class="flex rounded-xl border border-white/[0.06] overflow-hidden">
<button
@click="setView('grid')"
:class="['px-3 py-2.5 transition-all', viewMode === 'grid' ? 'bg-white/[0.08] text-white' : 'text-gray-600 hover:text-gray-400 hover:bg-white/[0.03]']"
title="Grid view"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
</svg>
</button>
<button
@click="setView('table')"
:class="['px-3 py-2.5 transition-all', viewMode === 'table' ? 'bg-white/[0.08] text-white' : 'text-gray-600 hover:text-gray-400 hover:bg-white/[0.03]']"
title="Table view"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" />
</svg>
</button>
</div>
</div>
<!-- Pagination controls -->
<div v-if="totalPages > 1" class="flex flex-wrap items-center justify-between gap-3">
<span class="text-sm text-gray-500">
Showing {{ rangeStart }}{{ rangeEnd }} of {{ filteredCount }}
</span>
<div class="flex items-center gap-1">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
class="rounded-lg border border-white/[0.06] px-3 py-1.5 text-sm transition-all disabled:opacity-30 disabled:cursor-not-allowed text-gray-400 hover:text-white hover:bg-white/[0.06]"
>
Prev
</button>
<button
v-for="p in visiblePages"
:key="p"
@click="goToPage(p)"
:class="['rounded-lg px-3 py-1.5 text-sm transition-all', p === currentPage ? 'bg-[var(--color-accent-500)] text-white font-medium' : 'border border-white/[0.06] text-gray-400 hover:text-white hover:bg-white/[0.06]']"
>
{{ p }}
</button>
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
class="rounded-lg border border-white/[0.06] px-3 py-1.5 text-sm transition-all disabled:opacity-30 disabled:cursor-not-allowed text-gray-400 hover:text-white hover:bg-white/[0.06]"
>
Next
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { ref, computed, watch, nextTick, onMounted } from 'vue';
const props = defineProps<{
authors?: string;
tags?: string;
totalCount?: number;
}>();
const authorList = props.authors
? props.authors.split(',').map(a => a.trim()).filter(Boolean)
: [];
const tagList = props.tags
? props.tags.split(',').map(t => t.trim()).filter(Boolean)
: [];
const query = ref('');
const forkFilter = ref('');
const viewMode = ref<'grid' | 'table'>('grid');
const currentPage = ref(1);
const filteredCount = ref(props.totalCount || 0);
watch(query, (val) => {
const q = val.toLowerCase().trim();
const cards = document.querySelectorAll<HTMLElement>('[data-skill]');
cards.forEach((card) => {
const PER_PAGE_GRID = 12;
const PER_PAGE_TABLE = 20;
const perPage = computed(() => viewMode.value === 'grid' ? PER_PAGE_GRID : PER_PAGE_TABLE);
const totalPages = computed(() => Math.max(1, Math.ceil(filteredCount.value / perPage.value)));
const rangeStart = computed(() => filteredCount.value === 0 ? 0 : (currentPage.value - 1) * perPage.value + 1);
const rangeEnd = computed(() => Math.min(currentPage.value * perPage.value, filteredCount.value));
const visiblePages = computed(() => {
const pages: number[] = [];
const total = totalPages.value;
const cur = currentPage.value;
const maxVisible = 7;
if (total <= maxVisible) {
for (let i = 1; i <= total; i++) pages.push(i);
} else {
const half = Math.floor(maxVisible / 2);
let start = Math.max(1, cur - half);
let end = start + maxVisible - 1;
if (end > total) {
end = total;
start = end - maxVisible + 1;
}
for (let i = start; i <= end; i++) pages.push(i);
}
return pages;
});
// --- Author pills ---
const selectedAuthors = ref<string[]>([]);
const authorQuery = ref('');
const authorOpen = ref(false);
const authorInputEl = ref<HTMLInputElement>();
const authorSuggestions = computed(() => {
const q = authorQuery.value.toLowerCase().trim();
const selected = new Set(selectedAuthors.value.map(a => a.toLowerCase()));
return authorList.filter(a => !selected.has(a.toLowerCase()) && (!q || a.toLowerCase().includes(q)));
});
let authorBlurTimer: ReturnType<typeof setTimeout>;
function onAuthorBlur() {
authorBlurTimer = setTimeout(() => { authorOpen.value = false; }, 200);
}
function addAuthor(author: string) {
clearTimeout(authorBlurTimer);
if (!selectedAuthors.value.some(a => a.toLowerCase() === author.toLowerCase())) {
selectedAuthors.value.push(author);
}
authorQuery.value = '';
authorInputEl.value?.focus();
}
function removeAuthor(author: string) {
selectedAuthors.value = selectedAuthors.value.filter(a => a !== author);
}
function onAuthorEnter() {
if (authorSuggestions.value.length > 0) {
addAuthor(authorSuggestions.value[0]);
}
}
function onAuthorBackspace() {
if (!authorQuery.value && selectedAuthors.value.length > 0) {
selectedAuthors.value.pop();
}
}
// --- Tag pills ---
const selectedTags = ref<string[]>([]);
const tagQuery = ref('');
const tagOpen = ref(false);
const tagInputEl = ref<HTMLInputElement>();
const tagSuggestions = computed(() => {
const q = tagQuery.value.toLowerCase().trim();
const selected = new Set(selectedTags.value.map(t => t.toLowerCase()));
return tagList.filter(t => !selected.has(t.toLowerCase()) && (!q || t.toLowerCase().includes(q)));
});
let tagBlurTimer: ReturnType<typeof setTimeout>;
function onTagBlur() {
tagBlurTimer = setTimeout(() => { tagOpen.value = false; }, 200);
}
function addTag(tag: string) {
clearTimeout(tagBlurTimer);
if (!selectedTags.value.some(t => t.toLowerCase() === tag.toLowerCase())) {
selectedTags.value.push(tag);
}
tagQuery.value = '';
tagInputEl.value?.focus();
}
function removeTag(tag: string) {
selectedTags.value = selectedTags.value.filter(t => t !== tag);
}
function onTagEnter() {
if (tagSuggestions.value.length > 0) {
addTag(tagSuggestions.value[0]);
}
}
function onTagBackspace() {
if (!tagQuery.value && selectedTags.value.length > 0) {
selectedTags.value.pop();
}
}
// --- View toggle ---
function setView(mode: 'grid' | 'table') {
viewMode.value = mode;
localStorage.setItem('skillsViewMode', mode);
const grid = document.getElementById('skills-grid');
const table = document.getElementById('skills-table');
if (grid && table) {
grid.classList.toggle('hidden', mode !== 'grid');
table.classList.toggle('hidden', mode !== 'table');
}
currentPage.value = 1;
nextTick(() => applyFilters());
}
onMounted(() => {
const saved = localStorage.getItem('skillsViewMode') as 'grid' | 'table' | null;
if (saved === 'table') {
setView('table');
}
});
// --- Filtering + Pagination ---
const hasActiveFilters = computed(() =>
query.value || selectedAuthors.value.length > 0 || selectedTags.value.length > 0 || forkFilter.value
);
function applyFilters() {
const q = query.value.toLowerCase().trim();
const authors = selectedAuthors.value.map(a => a.toLowerCase());
const tags = selectedTags.value.map(t => t.toLowerCase());
const minForks = forkFilter.value ? parseInt(forkFilter.value) : 0;
// Get the active container's items
const activeId = viewMode.value === 'grid' ? 'skills-grid' : 'skills-table';
const inactiveId = viewMode.value === 'grid' ? 'skills-table' : 'skills-grid';
const activeItems = Array.from(document.querySelectorAll<HTMLElement>(`#${activeId} [data-skill]`));
const inactiveItems = document.querySelectorAll<HTMLElement>(`#${inactiveId} [data-skill]`);
// Hide all inactive items
inactiveItems.forEach(el => el.style.display = 'none');
// Filter active items
const matching: HTMLElement[] = [];
activeItems.forEach((card) => {
const name = card.dataset.name || '';
const desc = card.dataset.description || '';
const tools = card.dataset.tools || '';
const match = !q || name.includes(q) || desc.includes(q) || tools.includes(q);
card.style.display = match ? '' : 'none';
const cardAuthor = card.dataset.author || '';
const cardTags = (card.dataset.tags || '').split(',').filter(Boolean);
const forks = parseInt(card.dataset.forks || '0');
const matchText = !q || name.includes(q) || desc.includes(q) || tools.includes(q) || cardTags.some(t => t.includes(q));
const matchAuthor = authors.length === 0 || authors.some(a => cardAuthor.includes(a));
const matchTag = tags.length === 0 || tags.every(t => cardTags.includes(t));
const matchForks = forks >= minForks;
if (matchText && matchAuthor && matchTag && matchForks) {
matching.push(card);
}
});
});
filteredCount.value = matching.length;
// Clamp page
const maxPage = Math.max(1, Math.ceil(matching.length / perPage.value));
if (currentPage.value > maxPage) {
currentPage.value = maxPage;
}
// Paginate
const start = (currentPage.value - 1) * perPage.value;
const end = start + perPage.value;
activeItems.forEach((card) => {
const idx = matching.indexOf(card);
if (idx === -1) {
card.style.display = 'none';
} else if (idx >= start && idx < end) {
card.style.display = '';
} else {
card.style.display = 'none';
}
});
}
function goToPage(page: number) {
if (page < 1 || page > totalPages.value) return;
currentPage.value = page;
applyFilters();
}
function reset() {
query.value = '';
selectedAuthors.value = [];
authorQuery.value = '';
selectedTags.value = [];
tagQuery.value = '';
forkFilter.value = '';
currentPage.value = 1;
}
// Reset to page 1 on any filter change, then apply
watch([query, selectedAuthors, selectedTags, forkFilter], () => {
currentPage.value = 1;
applyFilters();
}, { deep: true });
</script>

View File

@@ -5,7 +5,7 @@ interface Props {
title?: string;
}
const { title = 'Skillit' } = Astro.props;
const { title = 'Skills Here' } = Astro.props;
---
<!doctype html>
@@ -16,6 +16,7 @@ const { title = 'Skillit' } = Astro.props;
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
</head>
<body class="min-h-screen font-sans text-gray-300 antialiased">
@@ -25,14 +26,10 @@ const { title = 'Skillit' } = Astro.props;
</div>
<nav class="relative z-50 border-b border-white/[0.06] bg-surface-50/80 backdrop-blur-xl">
<div class="mx-auto max-w-5xl flex items-center justify-between px-6 py-4">
<div class="mx-auto max-w-6xl flex items-center justify-between px-6 py-4">
<a href="/" class="group flex items-center gap-2.5">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-accent-500 to-accent-600 shadow-lg shadow-accent-500/20">
<svg class="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</div>
<span class="text-lg font-bold tracking-tight text-white group-hover:text-accent-400 transition-colors">skillit</span>
<img src="/favicon.svg" alt="Skills Here" class="h-8 w-8" />
<span class="text-lg font-bold tracking-tight text-white group-hover:text-accent-400 transition-colors">Skills Here</span>
</a>
<a
href="/new"
@@ -46,7 +43,7 @@ const { title = 'Skillit' } = Astro.props;
</div>
</nav>
<main class="relative mx-auto max-w-5xl px-6 py-10">
<main class="relative mx-auto max-w-6xl px-6 py-10">
<slot />
</main>
</body>

View File

@@ -20,6 +20,10 @@ export interface SkillMeta {
'disable-model-invocation': boolean;
context: string;
agent: string;
author: string;
'author-email': string;
'fork-of': string;
tags: string[];
hooks: Record<string, unknown> | null;
}
@@ -55,6 +59,10 @@ function parseSkill(slug: string, raw: string): Skill {
'disable-model-invocation': Boolean(data['disable-model-invocation']),
context: (data.context as string) || '',
agent: (data.agent as string) || '',
author: (data.author as string) || '',
'author-email': (data['author-email'] as string) || '',
'fork-of': (data['fork-of'] as string) || '',
tags: parseTools(data.tags),
hooks: (typeof data.hooks === 'object' && data.hooks !== null) ? data.hooks as Record<string, unknown> : null,
content: content.trim(),
raw,
@@ -77,6 +85,16 @@ export async function listSkills(): Promise<SkillMeta[]> {
return skills.sort((a, b) => a.name.localeCompare(b.name));
}
export async function getAllTags(): Promise<string[]> {
const all = await listSkills();
return [...new Set(all.flatMap(s => s.tags))].sort();
}
export async function getForksOf(slug: string): Promise<SkillMeta[]> {
const all = await listSkills();
return all.filter(s => s['fork-of'] === slug);
}
export async function getSkill(slug: string): Promise<Skill | null> {
try {
const raw = await fs.readFile(skillPath(slug), 'utf-8');

59
src/lib/stats.ts Normal file
View File

@@ -0,0 +1,59 @@
import fs from 'node:fs/promises';
import path from 'node:path';
const STATS_FILE = path.resolve(process.env.STATS_FILE || 'data/stats.json');
export interface SkillStats {
downloads: number;
pushes: number;
lastPushedAt: string | null;
}
type StatsStore = Record<string, SkillStats>;
async function readStore(): Promise<StatsStore> {
try {
const raw = await fs.readFile(STATS_FILE, 'utf-8');
return JSON.parse(raw) as StatsStore;
} catch {
return {};
}
}
async function writeStore(store: StatsStore): Promise<void> {
const dir = path.dirname(STATS_FILE);
await fs.mkdir(dir, { recursive: true });
const tmp = STATS_FILE + '.tmp';
await fs.writeFile(tmp, JSON.stringify(store, null, 2), 'utf-8');
await fs.rename(tmp, STATS_FILE);
}
function ensure(store: StatsStore, slug: string): SkillStats {
if (!store[slug]) {
store[slug] = { downloads: 0, pushes: 0, lastPushedAt: null };
}
return store[slug];
}
export async function recordDownload(slug: string): Promise<void> {
const store = await readStore();
ensure(store, slug).downloads++;
await writeStore(store);
}
export async function recordPush(slug: string): Promise<void> {
const store = await readStore();
const entry = ensure(store, slug);
entry.pushes++;
entry.lastPushedAt = new Date().toISOString();
await writeStore(store);
}
export async function getStatsForSlug(slug: string): Promise<SkillStats> {
const store = await readStore();
return store[slug] || { downloads: 0, pushes: 0, lastPushedAt: null };
}
export async function getAllStats(): Promise<StatsStore> {
return readStore();
}

View File

@@ -1,5 +1,10 @@
import { listSkills } from './skills';
export function isPowerShell(request: Request): boolean {
const ua = request.headers.get('user-agent') || '';
return /PowerShell/i.test(ua);
}
export async function buildPushScript(baseUrl: string, skillsDir: string): Promise<string> {
const lines = [
'#!/usr/bin/env bash',
@@ -7,36 +12,139 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
'',
`SKILLS_DIR="${skillsDir}"`,
`BASE_URL="${baseUrl}"`,
'FILTER="${1:-}"',
'TOKEN_FILE="$HOME/.claude/skills.here-token"',
'',
'# Get git author if available',
'AUTHOR_NAME=$(git config user.name 2>/dev/null || echo "")',
'AUTHOR_EMAIL=$(git config user.email 2>/dev/null || echo "")',
'',
'# Load or register token',
'TOKEN=""',
'if [ -f "$TOKEN_FILE" ]; then',
' TOKEN=$(cat "$TOKEN_FILE")',
'elif [ -n "$AUTHOR_EMAIL" ]; then',
' echo "No token found. Registering with $AUTHOR_EMAIL..."',
' REGISTER_RESPONSE=$(curl -sS -X POST \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"email\\": \\"$AUTHOR_EMAIL\\", \\"name\\": \\"$AUTHOR_NAME\\"}" \\',
' -w "\\n%{http_code}" \\',
' "$BASE_URL/api/auth/register")',
' REGISTER_BODY=$(echo "$REGISTER_RESPONSE" | sed \'$d\')',
' REGISTER_STATUS=$(echo "$REGISTER_RESPONSE" | tail -1)',
' if [ "$REGISTER_STATUS" = "201" ]; then',
' TOKEN=$(echo "$REGISTER_BODY" | jq -r .token)',
' mkdir -p "$(dirname "$TOKEN_FILE")"',
' echo "$TOKEN" > "$TOKEN_FILE"',
' chmod 600 "$TOKEN_FILE"',
' echo " Token saved to $TOKEN_FILE"',
' elif [ "$REGISTER_STATUS" = "409" ]; then',
' echo " Email already registered. Place your token in $TOKEN_FILE"',
' echo " Continuing without token (unprotected skills only)..."',
' else',
' echo " Registration failed ($REGISTER_STATUS): $REGISTER_BODY"',
' echo " Continuing without token (unprotected skills only)..."',
' fi',
'fi',
'',
'if [ ! -d "$SKILLS_DIR" ]; then',
' echo "No skills directory found at $SKILLS_DIR"',
' exit 1',
'fi',
'',
'count=0',
'for file in "$SKILLS_DIR"/*.md; do',
' [ -f "$file" ] || continue',
' slug=$(basename "$file" .md)',
' content=$(cat "$file")',
'AUTH_HEADER=""',
'if [ -n "$TOKEN" ]; then',
' AUTH_HEADER="Authorization: Bearer $TOKEN"',
'fi',
'',
'push_skill() {',
' local file="$1"',
' local slug=$(basename "$file" .md)',
' local content=$(cat "$file")',
'',
' # Inject author into frontmatter if available and not already present',
' if [ -n "$AUTHOR_EMAIL" ] && ! echo "$content" | grep -q "^author-email:"; then',
' content=$(echo "$content" | awk -v name="$AUTHOR_NAME" -v email="$AUTHOR_EMAIL" \'NR==1 && /^---$/{print; if (name) print "author: " name; print "author-email: " email; next} {print}\')',
' fi',
'',
' # Build curl auth args',
' local auth_args=""',
' if [ -n "$AUTH_HEADER" ]; then',
' auth_args="-H \\"$AUTH_HEADER\\""',
' fi',
'',
' # Try PUT (update), fallback to POST (create)',
' status=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills/$slug")',
'',
' if [ "$status" = "404" ]; then',
' curl -fsS -X POST \\',
' local response',
' if [ -n "$TOKEN" ]; then',
' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills" > /dev/null',
' -H "Authorization: Bearer $TOKEN" \\',
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills/$slug")',
' else',
' response=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills/$slug")',
' fi',
'',
' if [ "$response" = "403" ]; then',
' echo " ✗ $slug (permission denied — token missing or invalid)"',
' return 1',
' fi',
'',
' if [ "$response" = "404" ]; then',
' if [ -n "$TOKEN" ]; then',
' local post_status=$(curl -sS -o /dev/null -w "%{http_code}" -X POST \\',
' -H "Content-Type: application/json" \\',
' -H "Authorization: Bearer $TOKEN" \\',
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills")',
' else',
' local post_status=$(curl -sS -o /dev/null -w "%{http_code}" -X POST \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills")',
' fi',
' if [ "$post_status" = "403" ]; then',
' echo " ✗ $slug (permission denied — token missing or invalid)"',
' return 1',
' fi',
' fi',
'',
' echo " ✓ $slug"',
' count=$((count + 1))',
'done',
'}',
'',
'count=0',
'failed=0',
'if [ -n "$FILTER" ]; then',
' # Push a specific skill',
' file="$SKILLS_DIR/${FILTER%.md}.md"',
' if [ ! -f "$file" ]; then',
' echo "Skill not found: $file"',
' exit 1',
' fi',
' if push_skill "$file"; then',
' count=1',
' else',
' failed=1',
' fi',
'else',
' # Push all skills',
' for file in "$SKILLS_DIR"/*.md; do',
' [ -f "$file" ] || continue',
' if push_skill "$file"; then',
' count=$((count + 1))',
' else',
' failed=$((failed + 1))',
' fi',
' done',
'fi',
'',
'echo "Pushed $count skill(s) to $BASE_URL"',
'if [ "$failed" -gt 0 ]; then',
' echo "$failed skill(s) failed (permission denied)"',
'fi',
'',
];
@@ -74,3 +182,142 @@ export async function buildSyncScript(baseUrl: string, skillsDir: string): Promi
lines.push('');
return lines.join('\n');
}
export async function buildSyncScriptPS(baseUrl: string, skillsDir: string): Promise<string> {
const skills = await listSkills();
const lines = [
'$ErrorActionPreference = "Stop"',
'',
`$SkillsDir = "${skillsDir}"`,
'New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null',
'',
];
if (skills.length === 0) {
lines.push('Write-Host "No skills available to sync."');
} else {
lines.push(`Write-Host "Syncing ${skills.length} skill(s) from ${baseUrl}..."`);
lines.push('');
for (const skill of skills) {
const skillUrl = `${baseUrl}/${skill.slug}`;
lines.push(`Invoke-WebRequest -Uri "${skillUrl}" -OutFile (Join-Path $SkillsDir "${skill.slug}.md")`);
lines.push(`Write-Host " ✓ ${skill.name}"`);
}
lines.push('');
lines.push('Write-Host "Done! Skills synced to $SkillsDir"');
}
lines.push('');
return lines.join('\n');
}
export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Promise<string> {
const lines = [
'$ErrorActionPreference = "Stop"',
'',
`$SkillsDir = "${skillsDir}"`,
`$BaseUrl = "${baseUrl}"`,
'$Filter = if ($args.Count -gt 0) { $args[0] } else { "" }',
'$TokenFile = Join-Path $HOME ".claude\\skills.here-token"',
'',
'# Get git author if available',
'$AuthorName = try { git config user.name 2>$null } catch { "" }',
'$AuthorEmail = try { git config user.email 2>$null } catch { "" }',
'',
'# Load or register token',
'$Token = ""',
'if (Test-Path $TokenFile) {',
' $Token = (Get-Content $TokenFile -Raw).Trim()',
'} elseif ($AuthorEmail) {',
' Write-Host "No token found. Registering with $AuthorEmail..."',
' try {',
' $body = @{ email = $AuthorEmail; name = $AuthorName } | ConvertTo-Json',
' $resp = Invoke-WebRequest -Uri "$BaseUrl/api/auth/register" -Method POST -ContentType "application/json" -Body $body',
' if ($resp.StatusCode -eq 201) {',
' $Token = ($resp.Content | ConvertFrom-Json).token',
' New-Item -ItemType Directory -Force -Path (Split-Path $TokenFile) | Out-Null',
' Set-Content -Path $TokenFile -Value $Token',
' Write-Host " Token saved to $TokenFile"',
' }',
' } catch {',
' $code = $_.Exception.Response.StatusCode.value__',
' if ($code -eq 409) {',
' Write-Host " Email already registered. Place your token in $TokenFile"',
' } else {',
' Write-Host " Registration failed: $_"',
' }',
' Write-Host " Continuing without token (unprotected skills only)..."',
' }',
'}',
'',
'if (-not (Test-Path $SkillsDir)) {',
' Write-Host "No skills directory found at $SkillsDir"',
' exit 1',
'}',
'',
'$headers = @{ "Content-Type" = "application/json" }',
'if ($Token) { $headers["Authorization"] = "Bearer $Token" }',
'',
'function Push-Skill($file) {',
' $slug = [IO.Path]::GetFileNameWithoutExtension($file)',
' $content = Get-Content $file -Raw',
'',
' # Inject author if available and not present',
' if ($AuthorEmail -and $content -notmatch "(?m)^author-email:") {',
' $inject = ""',
' if ($AuthorName) { $inject += "author: $AuthorName`n" }',
' $inject += "author-email: $AuthorEmail"',
' $content = $content -replace "^---$", "---`n$inject" -replace "^---`n", "---`n"',
' }',
'',
' $body = @{ content = $content } | ConvertTo-Json',
' try {',
' Invoke-WebRequest -Uri "$BaseUrl/api/skills/$slug" -Method PUT -Headers $headers -Body $body | Out-Null',
' Write-Host " ✓ $slug"',
' return $true',
' } catch {',
' $code = $_.Exception.Response.StatusCode.value__',
' if ($code -eq 403) {',
' Write-Host " ✗ $slug (permission denied)"',
' return $false',
' }',
' if ($code -eq 404) {',
' $postBody = @{ slug = $slug; content = $content } | ConvertTo-Json',
' try {',
' Invoke-WebRequest -Uri "$BaseUrl/api/skills" -Method POST -Headers $headers -Body $postBody | Out-Null',
' Write-Host " ✓ $slug"',
' return $true',
' } catch {',
' $postCode = $_.Exception.Response.StatusCode.value__',
' if ($postCode -eq 403) {',
' Write-Host " ✗ $slug (permission denied)"',
' return $false',
' }',
' }',
' }',
' }',
' Write-Host " ✓ $slug"',
' return $true',
'}',
'',
'$count = 0; $failed = 0',
'if ($Filter) {',
' $file = Join-Path $SkillsDir "$($Filter -replace \'\\.md$\',\'\').md"',
' if (-not (Test-Path $file)) { Write-Host "Skill not found: $file"; exit 1 }',
' if (Push-Skill $file) { $count++ } else { $failed++ }',
'} else {',
' Get-ChildItem -Path $SkillsDir -Filter "*.md" | ForEach-Object {',
' if (Push-Skill $_.FullName) { $count++ } else { $failed++ }',
' }',
'}',
'',
'Write-Host "Pushed $count skill(s) to $BaseUrl"',
'if ($failed -gt 0) { Write-Host "$failed skill(s) failed (permission denied)" }',
'',
];
return lines.join('\n');
}

70
src/lib/tokens.ts Normal file
View File

@@ -0,0 +1,70 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import crypto from 'node:crypto';
const TOKENS_FILE = path.resolve(process.env.TOKENS_FILE || 'data/tokens.json');
interface TokenEntry {
hash: string;
name: string;
createdAt: string;
}
type TokenStore = Record<string, TokenEntry>;
async function readStore(): Promise<TokenStore> {
try {
const raw = await fs.readFile(TOKENS_FILE, 'utf-8');
return JSON.parse(raw) as TokenStore;
} catch {
return {};
}
}
async function writeStore(store: TokenStore): Promise<void> {
const dir = path.dirname(TOKENS_FILE);
await fs.mkdir(dir, { recursive: true });
const tmp = TOKENS_FILE + '.tmp';
await fs.writeFile(tmp, JSON.stringify(store, null, 2), 'utf-8');
await fs.rename(tmp, TOKENS_FILE);
}
function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
export async function generateToken(email: string, name: string): Promise<string> {
const store = await readStore();
if (store[email]) {
throw new Error('Email already registered');
}
const token = crypto.randomBytes(32).toString('hex');
store[email] = {
hash: hashToken(token),
name,
createdAt: new Date().toISOString(),
};
await writeStore(store);
return token;
}
export async function verifyToken(email: string, token: string): Promise<boolean> {
if (!email || !token) return false;
const store = await readStore();
const entry = store[email];
if (!entry) return false;
return entry.hash === hashToken(token);
}
export async function hasToken(email: string): Promise<boolean> {
const store = await readStore();
return Boolean(store[email]);
}
export function extractBearerToken(request: Request): string {
const auth = request.headers.get('Authorization') || '';
const match = auth.match(/^Bearer\s+(\S+)$/i);
return match ? match[1] : '';
}

View File

@@ -1,7 +1,10 @@
---
import Base from '../layouts/Base.astro';
import EditGate from '../components/EditGate.vue';
import DeleteButton from '../components/DeleteButton.vue';
import { getSkill } from '../lib/skills';
import { getSkill, getForksOf } from '../lib/skills';
import { hasToken } from '../lib/tokens';
import { recordDownload, getStatsForSlug } from '../lib/stats';
import { marked } from 'marked';
const { slug } = Astro.params;
@@ -14,85 +17,171 @@ if (!skill) {
// curl / wget → raw markdown
const accept = Astro.request.headers.get('accept') || '';
if (!accept.includes('text/html')) {
recordDownload(slug!);
return new Response(skill.raw, {
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
});
}
const authorHasToken = skill['author-email'] ? await hasToken(skill['author-email']) : false;
const forks = await getForksOf(slug!);
const stats = await getStatsForSlug(slug!);
const html = await marked(skill.content);
const installCmd = `curl -fsSL ${Astro.url.origin}/${slug} -o .claude/skills/${slug}.md`;
const origin = Astro.url.origin;
const cmds = {
unix: `curl -fsSL ${origin}/${slug}/i | bash`,
unixGlobal: `curl -fsSL ${origin}/${slug}/gi | bash`,
win: `irm ${origin}/${slug}/i | iex`,
winGlobal: `irm ${origin}/${slug}/gi | iex`,
};
---
<Base title={`${skill.name} — Skillit`}>
<div class="mb-8">
<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
Back
</a>
<Base title={`${skill.name} — Skills Here`}>
<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
Back
</a>
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight text-white">{skill.name}</h1>
{skill.description && <p class="text-gray-500 mt-1.5 leading-relaxed">{skill.description}</p>}
</div>
<div class="flex items-center gap-2 shrink-0">
<a
href={`/${slug}/edit`}
class="inline-flex items-center gap-1.5 rounded-lg border border-white/[0.08] bg-surface-200 px-3.5 py-2 text-sm font-medium text-gray-300 hover:border-white/[0.15] hover:text-white transition-all"
>
<!-- Header + Install -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem;">
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6" style="min-width: 0;">
<h1 class="text-2xl font-bold tracking-tight text-white mb-1">{skill.name}</h1>
{skill.description && <p class="text-gray-500 leading-relaxed mb-3">{skill.description}</p>}
{skill['allowed-tools'].length > 0 && (
<div class="flex flex-wrap gap-1.5">
{skill['allowed-tools'].map((tool) => (
<span class="rounded-md bg-white/[0.04] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400">
{tool}
</span>
))}
</div>
)}
{skill.tags.length > 0 && (
<div class="flex flex-wrap gap-1.5 mt-3">
{skill.tags.map((tag) => (
<span class="rounded-full bg-[var(--color-accent-500)]/10 px-2.5 py-0.5 text-xs font-medium text-[var(--color-accent-400)]">
{tag}
</span>
))}
</div>
)}
{skill.author && (
<p class="text-xs text-gray-600 mt-3">by {skill.author}</p>
)}
{skill['fork-of'] && (
<p class="text-xs text-gray-600 mt-1">forked from <a href={`/${skill['fork-of']}`} class="text-[var(--color-accent-500)] hover:text-[var(--color-accent-400)] transition-colors">{skill['fork-of']}</a></p>
)}
{forks.length > 0 && (
<details class="mt-3">
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-300 transition-colors select-none">
{forks.length} fork{forks.length !== 1 ? 's' : ''}
</summary>
<ul class="mt-1.5 space-y-1 pl-3 border-l border-white/[0.06]">
{forks.map((f) => (
<li>
<a href={`/${f.slug}`} class="text-xs text-[var(--color-accent-500)] hover:text-[var(--color-accent-400)] transition-colors">
{f.name}
{f.author && <span class="text-gray-600"> by {f.author}</span>}
</a>
</li>
))}
</ul>
</details>
)}
<div class="flex flex-wrap items-center gap-4 mt-4 pt-3 border-t border-white/[0.06]">
<span class="inline-flex items-center gap-1.5 text-xs text-gray-500">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Edit
</a>
<DeleteButton slug={slug!} client:load />
{stats.downloads} download{stats.downloads !== 1 ? 's' : ''}
</span>
<span class="inline-flex items-center gap-1.5 text-xs text-gray-500">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
</svg>
{stats.pushes} push{stats.pushes !== 1 ? 'es' : ''}
</span>
{stats.lastPushedAt && (
<span class="text-xs text-gray-600">
Last updated {new Date(stats.lastPushedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
)}
</div>
</div>
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6 space-y-4" style="min-width: 0;">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-white">Install this skill</h2>
<div class="flex rounded-lg border border-white/[0.06] overflow-hidden" id="os-tabs">
<button data-os="unix" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">macOS / Linux</button>
<button data-os="win" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">Windows</button>
</div>
</div>
<p class="text-xs text-gray-500 leading-relaxed">Run in your project root to add this skill.</p>
<div class="flex items-center gap-3 rounded-xl bg-surface-50 border border-white/[0.06] px-4 py-3">
<code data-cmd="unix" class="flex-1 text-xs font-mono text-gray-500 select-all truncate">{cmds.unix}</code>
<code data-cmd="win" class="flex-1 text-xs font-mono text-gray-500 select-all truncate hidden">{cmds.win}</code>
<button data-copy class="shrink-0 rounded-md bg-white/[0.06] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
</div>
<details class="group">
<summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-400 transition-colors">More options</summary>
<div class="mt-3 space-y-3 text-xs text-gray-500">
<div>
<p class="mb-1.5">Install globally (available in all projects):</p>
<div class="flex items-center gap-3 rounded-lg bg-surface-50 border border-white/[0.06] px-3 py-2">
<code data-cmd="unix" class="flex-1 font-mono text-gray-500 select-all truncate">{cmds.unixGlobal}</code>
<code data-cmd="win" class="flex-1 font-mono text-gray-500 select-all truncate hidden">{cmds.winGlobal}</code>
<button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
</div>
</div>
</div>
</details>
</div>
</div>
{skill['allowed-tools'].length > 0 && (
<div class="flex flex-wrap gap-1.5 mb-8">
{skill['allowed-tools'].map((tool) => (
<span class="rounded-md bg-white/[0.04] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400">
{tool}
</span>
))}
<!-- Skill content -->
<div class="relative rounded-2xl border border-white/[0.06] bg-surface-100 p-8">
<div class="absolute top-4 right-4 flex items-center gap-2">
<EditGate slug={slug!} authorEmail={skill['author-email']} authorName={skill.author} authorHasToken={authorHasToken} client:load />
<DeleteButton slug={slug!} authorEmail={skill['author-email']} authorName={skill.author} authorHasToken={authorHasToken} client:load />
</div>
)}
<!-- Install & usage -->
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6 mb-8 max-w-2xl space-y-4">
<h2 class="text-sm font-semibold text-white">Install this skill</h2>
<p class="text-xs text-gray-500 leading-relaxed">Run this in your project root. The skill file will be saved to <code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded">.claude/skills/{slug}.md</code> and Claude Code will load it automatically.</p>
<div class="flex items-center gap-3 rounded-xl bg-surface-50 border border-white/[0.06] px-4 py-3">
<code class="flex-1 text-xs font-mono text-gray-500 select-all truncate">{installCmd}</code>
<button
id="copy-install"
class="shrink-0 rounded-md bg-white/[0.06] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] transition-all"
>
Copy
</button>
</div>
{skill['allowed-tools'].length > 0 && (
<p class="text-xs text-gray-600 leading-relaxed">This skill uses: {skill['allowed-tools'].join(', ')}. Claude will have access to these tools when this skill is active.</p>
)}
<article class="skill-prose" set:html={html} />
</div>
<article class="skill-prose rounded-2xl border border-white/[0.06] bg-surface-100 p-8" set:html={html} />
</Base>
<style>
.os-tab { color: var(--color-gray-600); }
.os-tab.active { background: rgba(255,255,255,0.06); color: white; }
</style>
<script>
const btn = document.getElementById('copy-install');
if (btn) {
// OS detection + tab switching
const isWin = /Win/.test(navigator.platform);
function setOS(os: string) {
document.querySelectorAll<HTMLElement>('[data-cmd]').forEach(el => {
el.classList.toggle('hidden', el.dataset.cmd !== os);
});
document.querySelectorAll<HTMLElement>('.os-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.os === os);
});
}
setOS(isWin ? 'win' : 'unix');
document.querySelectorAll<HTMLButtonElement>('.os-tab').forEach(tab => {
tab.addEventListener('click', () => setOS(tab.dataset.os!));
});
// Copy buttons
document.querySelectorAll<HTMLButtonElement>('[data-copy]').forEach((btn) => {
btn.addEventListener('click', () => {
const code = btn.previousElementSibling?.textContent?.trim();
const container = btn.parentElement!;
const visible = container.querySelector<HTMLElement>('[data-cmd]:not(.hidden)');
const code = visible?.textContent?.trim();
if (code) {
navigator.clipboard.writeText(code);
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 1500);
}
});
}
});
</script>

View File

@@ -1,7 +1,7 @@
---
import Base from '../../layouts/Base.astro';
import SkillEditor from '../../components/SkillEditor.vue';
import { getSkill } from '../../lib/skills';
import { getSkill, getAllTags } from '../../lib/skills';
import { getAvailableTools } from '../../lib/tools';
import { getAvailableModels } from '../../lib/models';
@@ -16,9 +16,10 @@ const availableTools = await getAvailableTools();
const availableModels = await getAvailableModels();
const allowedTools = skill['allowed-tools'].join(', ');
const hooksJson = skill.hooks ? JSON.stringify(skill.hooks, null, 2) : '';
const availableTags = await getAllTags();
---
<Base title={`Edit ${skill.name} — Skillit`}>
<Base title={`Edit ${skill.name} — Skills Here`}>
<a href={`/${slug}`} class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
@@ -41,8 +42,12 @@ const hooksJson = skill.hooks ? JSON.stringify(skill.hooks, null, 2) : '';
initialAgent={skill.agent}
initialHooks={hooksJson}
initialBody={skill.content}
initialAuthor={skill.author}
initialAuthorEmail={skill['author-email']}
initialTags={skill.tags.join(', ')}
:availableTools={availableTools}
:availableModels={availableModels}
availableTags={availableTags.join(',')}
client:load
/>
</Base>

36
src/pages/[slug]/gi.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { APIRoute } from 'astro';
import { getSkill } from '../../lib/skills';
import { isPowerShell } from '../../lib/sync';
export const GET: APIRoute = async ({ params, url, request }) => {
const { slug } = params;
const skill = await getSkill(slug!);
if (!skill) {
return new Response('Skill not found', { status: 404 });
}
const origin = url.origin;
const ps = isPowerShell(request);
const script = ps
? [
'$ErrorActionPreference = "Stop"',
'$Dir = Join-Path $HOME ".claude\\skills"',
'New-Item -ItemType Directory -Force -Path $Dir | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${skill.name} globally to $Dir\\${slug}.md"`,
'',
].join('\n')
: [
'#!/usr/bin/env bash',
'set -euo pipefail',
'mkdir -p ~/.claude/skills',
`curl -fsSL "${origin}/${slug}" -o ~/.claude/skills/${slug}.md`,
`echo "✓ Installed ${skill.name} globally to ~/.claude/skills/${slug}.md"`,
'',
].join('\n');
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};

36
src/pages/[slug]/i.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { APIRoute } from 'astro';
import { getSkill } from '../../lib/skills';
import { isPowerShell } from '../../lib/sync';
export const GET: APIRoute = async ({ params, url, request }) => {
const { slug } = params;
const skill = await getSkill(slug!);
if (!skill) {
return new Response('Skill not found', { status: 404 });
}
const origin = url.origin;
const ps = isPowerShell(request);
const script = ps
? [
'$ErrorActionPreference = "Stop"',
'$Dir = ".claude\\skills"',
'New-Item -ItemType Directory -Force -Path $Dir | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${skill.name} to $Dir\\${slug}.md"`,
'',
].join('\n')
: [
'#!/usr/bin/env bash',
'set -euo pipefail',
'mkdir -p .claude/skills',
`curl -fsSL "${origin}/${slug}" -o ".claude/skills/${slug}.md"`,
`echo "✓ Installed ${skill.name} to .claude/skills/${slug}.md"`,
'',
].join('\n');
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};

View File

@@ -0,0 +1,50 @@
import type { APIRoute } from 'astro';
import { generateToken, hasToken } from '../../../lib/tokens';
export const POST: APIRoute = async ({ request }) => {
let body: { email?: string; name?: string };
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const { email, name } = body;
if (!email) {
return new Response(JSON.stringify({ error: 'email is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
if (await hasToken(email)) {
return new Response(JSON.stringify({ error: 'Email already registered' }), {
status: 409,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const token = await generateToken(email, name || '');
return new Response(JSON.stringify({ token }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
if (message.includes('already registered')) {
return new Response(JSON.stringify({ error: message }), {
status: 409,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};

View File

@@ -0,0 +1,35 @@
import type { APIRoute } from 'astro';
import { verifyToken } from '../../../lib/tokens';
export const POST: APIRoute = async ({ request }) => {
let body: { email?: string; token?: string };
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const { email, token } = body;
if (!email || !token) {
return new Response(JSON.stringify({ error: 'email and token are required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const valid = await verifyToken(email, token);
if (!valid) {
return new Response(JSON.stringify({ error: 'Invalid token' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' },
});
};

View File

@@ -1,5 +1,8 @@
import type { APIRoute } from 'astro';
import matter from 'gray-matter';
import { getSkill, updateSkill, deleteSkill } from '../../../lib/skills';
import { verifyToken, extractBearerToken, hasToken } from '../../../lib/tokens';
import { recordPush } from '../../../lib/stats';
export const GET: APIRoute = async ({ params }) => {
const skill = await getSkill(params.slug!);
@@ -30,18 +33,33 @@ export const PUT: APIRoute = async ({ params, request }) => {
}
try {
const existing = await getSkill(params.slug!);
if (!existing) {
return new Response(JSON.stringify({ error: 'Skill not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
// Author check: if the skill has an author-email with a registered token, verify it
if (existing['author-email'] && await hasToken(existing['author-email'])) {
const token = extractBearerToken(request);
const valid = await verifyToken(existing['author-email'], token);
if (!valid) {
return new Response(JSON.stringify({ error: `Only ${existing.author || existing['author-email']} can update this skill. Provide a valid token via Authorization: Bearer header.` }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
const skill = await updateSkill(params.slug!, body.content);
recordPush(params.slug!);
return new Response(JSON.stringify(skill), {
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
if (message.includes('not found')) {
return new Response(JSON.stringify({ error: message }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
@@ -49,18 +67,32 @@ export const PUT: APIRoute = async ({ params, request }) => {
}
};
export const DELETE: APIRoute = async ({ params }) => {
export const DELETE: APIRoute = async ({ params, request }) => {
try {
await deleteSkill(params.slug!);
return new Response(null, { status: 204 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
if (message.includes('not found')) {
return new Response(JSON.stringify({ error: message }), {
const existing = await getSkill(params.slug!);
if (!existing) {
return new Response(JSON.stringify({ error: 'Skill not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
// Author check: if the skill has an author-email with a registered token, verify it
if (existing['author-email'] && await hasToken(existing['author-email'])) {
const token = extractBearerToken(request);
const valid = await verifyToken(existing['author-email'], token);
if (!valid) {
return new Response(JSON.stringify({ error: `Only ${existing.author || existing['author-email']} can delete this skill. Provide a valid token via Authorization: Bearer header.` }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
await deleteSkill(params.slug!);
return new Response(null, { status: 204 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },

View File

@@ -1,5 +1,8 @@
import type { APIRoute } from 'astro';
import matter from 'gray-matter';
import { listSkills, createSkill, isValidSlug } from '../../../lib/skills';
import { verifyToken, extractBearerToken, hasToken } from '../../../lib/tokens';
import { recordPush } from '../../../lib/stats';
export const GET: APIRoute = async () => {
const skills = await listSkills();
@@ -35,8 +38,23 @@ export const POST: APIRoute = async ({ request }) => {
});
}
// If content has author-email AND that email has a registered token, require it
const parsed = matter(content);
const authorEmail = (parsed.data['author-email'] as string) || '';
if (authorEmail && await hasToken(authorEmail)) {
const token = extractBearerToken(request);
const valid = await verifyToken(authorEmail, token);
if (!valid) {
return new Response(JSON.stringify({ error: 'Valid token required to create a skill with author-email. Register first via POST /api/auth/register.' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
try {
const skill = await createSkill(slug, content);
recordPush(slug);
return new Response(JSON.stringify(skill), {
status: 201,
headers: { 'Content-Type': 'application/json' },

View File

@@ -1,8 +1,11 @@
import type { APIRoute } from 'astro';
import { buildSyncScript } from '../lib/sync';
import { buildSyncScript, buildSyncScriptPS, isPowerShell } from '../lib/sync';
export const GET: APIRoute = async ({ url }) => {
const script = await buildSyncScript(url.origin, '$HOME/.claude/skills');
export const GET: APIRoute = async ({ url, request }) => {
const ps = isPowerShell(request);
const script = ps
? await buildSyncScriptPS(url.origin, '$HOME\\.claude\\skills')
: await buildSyncScript(url.origin, '$HOME/.claude/skills');
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});

View File

@@ -3,20 +3,35 @@ import Base from '../layouts/Base.astro';
import SkillCard from '../components/SkillCard.astro';
import SkillSearch from '../components/SkillSearch.vue';
import { listSkills } from '../lib/skills';
import { buildSyncScript } from '../lib/sync';
import { getAllStats } from '../lib/stats';
import { buildSyncScript, buildSyncScriptPS, isPowerShell } from '../lib/sync';
const accept = Astro.request.headers.get('accept') || '';
if (!accept.includes('text/html')) {
const script = await buildSyncScript(Astro.url.origin, '.claude/skills');
const ps = isPowerShell(Astro.request);
const script = ps
? await buildSyncScriptPS(Astro.url.origin, '.claude\\skills')
: await buildSyncScript(Astro.url.origin, '.claude/skills');
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
const skills = await listSkills();
// Compute fork counts and unique authors
const forkCounts = new Map<string, number>();
for (const s of skills) {
if (s['fork-of']) {
forkCounts.set(s['fork-of'], (forkCounts.get(s['fork-of']) || 0) + 1);
}
}
const authors = [...new Set(skills.map(s => s.author).filter(Boolean))].sort();
const allTags = [...new Set(skills.flatMap(s => s.tags))].sort();
const allStats = await getAllStats();
---
<Base title="Skillit — Claude Code Skills">
<Base title="Skills">
{skills.length === 0 ? (
<div class="text-center py-24">
<div class="inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-surface-200 border border-white/[0.06] mb-6">
@@ -39,64 +54,187 @@ const skills = await listSkills();
) : (
<div>
<!-- Hero / Quick install -->
<div class="mb-10">
<h1 class="text-3xl font-extrabold tracking-tight text-white mb-2">Skills</h1>
<p class="text-gray-500 mb-5 max-w-xl">Manage and distribute Claude Code skills. Skills are prompt files that Claude loads automatically to learn custom behaviors and workflows.</p>
<div class="mb-10 grid gap-6 lg:grid-cols-2 lg:items-start">
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6">
<h1 class="text-3xl font-extrabold tracking-tight text-white mb-2">Skills</h1>
<p class="text-gray-500 mb-3">Manage and distribute Claude Code skills. Skills are prompt files (<code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded text-xs">.md</code>) that Claude loads automatically to learn custom behaviors and workflows.</p>
<p class="text-gray-600 text-sm leading-relaxed">Create reusable skills to standardize how Claude handles commits, code reviews, testing, deployments, and more across your team. Share them instantly with a single curl command.</p>
</div>
<!-- Install instructions -->
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-6 max-w-2xl space-y-4">
<h2 class="text-sm font-semibold text-white">Quick install</h2>
<p class="text-xs text-gray-500 leading-relaxed">Run this in your project root to sync all skills. They'll be saved to <code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded">.claude/skills/</code> and Claude Code will pick them up automatically on the next conversation.</p>
<div class="flex items-center gap-3 rounded-xl bg-surface-50 border border-white/[0.06] px-4 py-3">
<code id="install-cmd" class="flex-1 text-sm font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin} | bash</code>
<button
id="copy-btn"
class="shrink-0 rounded-md bg-white/[0.06] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] transition-all"
>
Copy
</button>
</div>
<details class="group">
<summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-400 transition-colors">More options</summary>
<div class="mt-3 space-y-3 text-xs text-gray-500">
<div>
<p class="mb-1.5">Install globally (available in all projects):</p>
<div class="flex items-center gap-3 rounded-lg bg-surface-50 border border-white/[0.06] px-3 py-2">
<code class="flex-1 font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin}/gi | bash</code>
<button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
<!-- Quick install + Quick push -->
<div class="space-y-2">
<!-- Quick install -->
<details class="group rounded-2xl border border-white/[0.06] bg-surface-100">
<summary class="flex items-center justify-between cursor-pointer px-6 py-4 select-none">
<h2 class="text-sm font-semibold text-white">Quick install</h2>
<div class="flex items-center gap-3">
<div class="hidden group-open:flex rounded-lg border border-white/[0.06] overflow-hidden os-tabs" onclick="event.stopPropagation()">
<button data-os="unix" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">macOS / Linux</button>
<button data-os="win" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">Windows</button>
</div>
<svg class="h-4 w-4 text-gray-600 group-open:rotate-180 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</div>
<div>
<p class="mb-1.5">Push local skills to the server:</p>
<div class="flex items-center gap-3 rounded-lg bg-surface-50 border border-white/[0.06] px-3 py-2">
<code class="flex-1 font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin}/p | bash</code>
<button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
</summary>
<div class="px-6 pb-5 space-y-3">
<p class="text-xs text-gray-500 leading-relaxed">Sync all skills to your project. They'll be saved to <code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded">.claude/skills/</code> and Claude Code picks them up on the next conversation.</p>
<div class="flex items-center gap-3 rounded-xl bg-surface-50 border border-white/[0.06] px-4 py-3">
<code data-cmd="unix" class="flex-1 text-sm font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin} | bash</code>
<code data-cmd="win" class="flex-1 text-sm font-mono text-gray-400 select-all truncate hidden">irm {Astro.url.origin} | iex</code>
<button data-copy class="shrink-0 rounded-md bg-white/[0.06] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
</div>
<details class="group/inner">
<summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-400 transition-colors">Install globally</summary>
<div class="mt-2">
<div class="flex items-center gap-3 rounded-lg bg-surface-50 border border-white/[0.06] px-3 py-2">
<code data-cmd="unix" class="flex-1 text-xs font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin}/gi | bash</code>
<code data-cmd="win" class="flex-1 text-xs font-mono text-gray-400 select-all truncate hidden">irm {Astro.url.origin}/gi | iex</code>
<button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 text-xs font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
</div>
</div>
</details>
</div>
</details>
<!-- Quick push -->
<details class="group rounded-2xl border border-white/[0.06] bg-surface-100">
<summary class="flex items-center justify-between cursor-pointer px-6 py-4 select-none">
<h2 class="text-sm font-semibold text-white">Quick push</h2>
<div class="flex items-center gap-3">
<div class="hidden group-open:flex rounded-lg border border-white/[0.06] overflow-hidden os-tabs" onclick="event.stopPropagation()">
<button data-os="unix" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">macOS / Linux</button>
<button data-os="win" class="os-tab px-2.5 py-1 text-[11px] font-medium transition-all">Windows</button>
</div>
<svg class="h-4 w-4 text-gray-600 group-open:rotate-180 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</div>
</summary>
<div class="px-6 pb-5 space-y-3">
<p class="text-xs text-gray-500 leading-relaxed">Push your local skills to the server. Run from your project root — it reads <code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded">.claude/skills/*.md</code> and uploads them.</p>
<div class="flex items-center gap-3 rounded-xl bg-surface-50 border border-white/[0.06] px-4 py-3">
<code data-cmd="unix" class="flex-1 text-sm font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin}/p | bash</code>
<code data-cmd="win" class="flex-1 text-sm font-mono text-gray-400 select-all truncate hidden">irm {Astro.url.origin}/p | iex</code>
<button data-copy class="shrink-0 rounded-md bg-white/[0.06] border border-white/[0.06] px-2.5 py-1 text-xs font-medium text-gray-400 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
</div>
<details class="group/inner">
<summary class="text-xs text-gray-600 cursor-pointer hover:text-gray-400 transition-colors">Push a specific skill</summary>
<div class="mt-2">
<div class="flex items-center gap-3 rounded-lg bg-surface-50 border border-white/[0.06] px-3 py-2">
<code data-cmd="unix" class="flex-1 text-xs font-mono text-gray-400 select-all truncate">curl -fsSL {Astro.url.origin}/p | bash -s skill-name</code>
<code data-cmd="win" class="flex-1 text-xs font-mono text-gray-400 select-all truncate hidden">irm {Astro.url.origin}/p | iex -Args skill-name</code>
<button data-copy class="shrink-0 rounded bg-white/[0.06] border border-white/[0.06] px-2 py-0.5 text-xs font-medium text-gray-500 hover:text-white hover:bg-white/[0.1] transition-all">Copy</button>
</div>
</div>
</details>
</div>
</details>
</div>
</div>
<!-- Search + Grid -->
<SkillSearch client:load />
<!-- Search + Grid + Table -->
<SkillSearch authors={authors.join(',')} tags={allTags.join(',')} totalCount={skills.length} client:load />
<div id="skills-grid" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{skills.map((skill) => (
<div data-skill data-name={skill.name.toLowerCase()} data-description={skill.description.toLowerCase()} data-tools={skill['allowed-tools'].join(' ').toLowerCase()}>
<SkillCard {...skill} />
<div
data-skill
data-name={skill.name.toLowerCase()}
data-description={skill.description.toLowerCase()}
data-tools={skill['allowed-tools'].join(' ').toLowerCase()}
data-author={skill.author.toLowerCase()}
data-tags={skill.tags.join(',').toLowerCase()}
data-forks={String(forkCounts.get(skill.slug) || 0)}
>
<SkillCard {...skill} forkCount={forkCounts.get(skill.slug) || 0} downloads={allStats[skill.slug]?.downloads || 0} pushes={allStats[skill.slug]?.pushes || 0} lastPushedAt={allStats[skill.slug]?.lastPushedAt || null} />
</div>
))}
</div>
<div id="skills-table" class="hidden overflow-x-auto rounded-2xl border border-white/[0.06]">
<table class="w-full text-sm text-left">
<thead class="bg-surface-100 border-b border-white/[0.06]">
<tr>
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Name</th>
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Description</th>
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Tools</th>
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Tags</th>
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Author</th>
<th class="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-500">Updated</th>
</tr>
</thead>
<tbody class="divide-y divide-white/[0.04]">
{skills.map((skill) => {
const desc = skill.description.length > 80 ? skill.description.slice(0, 80) + '...' : skill.description;
const fc = forkCounts.get(skill.slug) || 0;
const st = allStats[skill.slug] || { downloads: 0, pushes: 0 };
return (
<tr
data-skill
data-name={skill.name.toLowerCase()}
data-description={skill.description.toLowerCase()}
data-tools={skill['allowed-tools'].join(' ').toLowerCase()}
data-author={skill.author.toLowerCase()}
data-tags={skill.tags.join(',').toLowerCase()}
data-forks={String(fc)}
class="bg-surface-50 hover:bg-surface-100 transition-colors"
>
<td class="px-4 py-3 whitespace-nowrap">
<a href={`/${skill.slug}`} class="font-medium text-white hover:text-accent-400 transition-colors">{skill.name}</a>
</td>
<td class="px-4 py-3 text-gray-500 max-w-xs truncate">{desc}</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
{skill['allowed-tools'].map((tool) => (
<span class="rounded-md bg-white/[0.04] border border-white/[0.06] px-1.5 py-0.5 text-[11px] font-medium text-gray-400">{tool}</span>
))}
</div>
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
{skill.tags.map((tag) => (
<span class="rounded-full bg-[var(--color-accent-500)]/10 border border-[var(--color-accent-500)]/20 px-2 py-0.5 text-[11px] font-medium text-[var(--color-accent-400)]">{tag}</span>
))}
</div>
</td>
<td class="px-4 py-3 text-gray-500 whitespace-nowrap">{skill.author || '—'}</td>
<td class="px-4 py-3 text-gray-500 whitespace-nowrap text-xs">{allStats[skill.slug]?.lastPushedAt ? new Date(allStats[skill.slug].lastPushedAt!).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</Base>
<style>
.os-tab { color: var(--color-gray-600, #6b7280); }
.os-tab.active { background: rgba(255,255,255,0.06); color: white; }
</style>
<script>
document.querySelectorAll<HTMLButtonElement>('#copy-btn, [data-copy]').forEach((btn) => {
// OS detection + tab switching
const isWin = /Win/.test(navigator.platform);
function setOS(os: string) {
document.querySelectorAll<HTMLElement>('[data-cmd]').forEach(el => {
el.classList.toggle('hidden', el.dataset.cmd !== os);
});
document.querySelectorAll<HTMLElement>('.os-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.os === os);
});
}
setOS(isWin ? 'win' : 'unix');
document.querySelectorAll<HTMLButtonElement>('.os-tab').forEach(tab => {
tab.addEventListener('click', () => setOS(tab.dataset.os!));
});
// Copy buttons
document.querySelectorAll<HTMLButtonElement>('[data-copy]').forEach((btn) => {
btn.addEventListener('click', () => {
const code = btn.previousElementSibling?.textContent?.trim();
const container = btn.parentElement!;
const visible = container.querySelector<HTMLElement>('[data-cmd]:not(.hidden)');
const code = visible?.textContent?.trim();
if (code) {
navigator.clipboard.writeText(code);
btn.textContent = 'Copied!';

View File

@@ -3,19 +3,58 @@ import Base from '../layouts/Base.astro';
import SkillEditor from '../components/SkillEditor.vue';
import { getAvailableTools } from '../lib/tools';
import { getAvailableModels } from '../lib/models';
import { getSkill, getAllTags } from '../lib/skills';
const availableTools = await getAvailableTools();
const availableModels = await getAvailableModels();
const availableTags = await getAllTags();
// Fork support: /new?from=original-slug
const fromSlug = Astro.url.searchParams.get('from');
let forkSource: Awaited<ReturnType<typeof getSkill>> = null;
if (fromSlug) {
forkSource = await getSkill(fromSlug);
}
const isFork = Boolean(forkSource);
const title = isFork ? `Fork ${forkSource!.name} — Skills Here` : 'New Skill — Skills Here';
---
<Base title="New Skill — Skillit">
<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4">
<Base title={title}>
<a href={isFork ? `/${fromSlug}` : '/'} class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-300 transition-colors mb-4">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
Back
{isFork ? `Back to ${forkSource!.name}` : 'Back'}
</a>
<h1 class="text-2xl font-bold tracking-tight text-white mb-2">New Skill</h1>
<p class="text-sm text-gray-500 mb-8 max-w-xl">Write a prompt in Markdown that tells Claude how to behave. The <strong class="text-gray-400">body</strong> is the instruction Claude receives. Use <strong class="text-gray-400">Allowed Tools</strong> to restrict which tools the skill can use.</p>
<SkillEditor mode="create" :availableTools={availableTools} :availableModels={availableModels} client:load />
<h1 class="text-2xl font-bold tracking-tight text-white mb-2">{isFork ? 'Fork Skill' : 'New Skill'}</h1>
{isFork ? (
<p class="text-sm text-gray-500 mb-8">Creating an independent copy of <strong class="text-gray-400">{forkSource!.name}</strong>. Change the <strong class="text-gray-400">name</strong> to generate a new slug before saving.</p>
) : (
<p class="text-sm text-gray-500 mb-8 max-w-xl">Write a prompt in Markdown that tells Claude how to behave. The <strong class="text-gray-400">body</strong> is the instruction Claude receives. Use <strong class="text-gray-400">Allowed Tools</strong> to restrict which tools the skill can use.</p>
)}
{isFork ? (
<SkillEditor
mode="create"
forkOf={fromSlug!}
initialName={forkSource!.name}
initialDescription={forkSource!.description}
initialAllowedTools={forkSource!['allowed-tools'].join(', ')}
initialArgumentHint={forkSource!['argument-hint']}
initialModel={forkSource!.model}
initialUserInvocable={forkSource!['user-invocable']}
initialDisableModelInvocation={forkSource!['disable-model-invocation']}
initialContext={forkSource!.context}
initialAgent={forkSource!.agent}
initialHooks={forkSource!.hooks ? JSON.stringify(forkSource!.hooks, null, 2) : ''}
initialBody={forkSource!.content}
initialTags={forkSource!.tags.join(', ')}
:availableTools={availableTools}
:availableModels={availableModels}
availableTags={availableTags.join(',')}
client:load
/>
) : (
<SkillEditor mode="create" :availableTools={availableTools} :availableModels={availableModels} availableTags={availableTags.join(',')} client:load />
)}
</Base>

View File

@@ -1,8 +1,11 @@
import type { APIRoute } from 'astro';
import { buildPushScript } from '../lib/sync';
import { buildPushScript, buildPushScriptPS, isPowerShell } from '../lib/sync';
export const GET: APIRoute = async ({ url }) => {
const script = await buildPushScript(url.origin, '.claude/skills');
export const GET: APIRoute = async ({ url, request }) => {
const ps = isPowerShell(request);
const script = ps
? await buildPushScriptPS(url.origin, '.claude\\skills')
: await buildPushScript(url.origin, '.claude/skills');
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});