Compare commits

..

2 Commits

Author SHA1 Message Date
Alejandro Martinez
c1a9442868 Add remaining source changes: resource data, refactored pages, updated styles
- Add example resources (agent, output-style, rule, skill)
   - Refactor legacy skill pages to use generic resource system
   - Update favicon, global styles, models, skills lib, and stats
   - Update PLAN.md
2026-02-13 14:28:26 +01:00
Alejandro Martinez
17423fb3b9 Rename to Grimoired, update domain to grimoi.red, add resource system
- Rename Grimaired -> Grimoired everywhere (title, nav, descriptions, token keys)
- Update domain from skills.here.run.place to grimoi.red
- Add Grimoired logo with description on homepage
- Add accordion behavior for Quick install / Quick push sections
- Add generic resource system (skills, agents, output-styles, rules)
- Add resource registry, editor, search, and file manager components
2026-02-13 14:25:07 +01:00
85 changed files with 4560 additions and 13096 deletions

1
.gitignore vendored
View File

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

View File

@@ -9,12 +9,15 @@ FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/data/skills ./data/skills
COPY --from=build /app/data ./data
ENV HOST=0.0.0.0
ENV PORT=4321
ENV SKILLS_DIR=/app/data/skills
ENV SITE_URL=https://skills.here.run.place
ENV AGENTS_DIR=/app/data/agents
ENV OUTPUT_STYLES_DIR=/app/data/output-styles
ENV RULES_DIR=/app/data/rules
ENV SITE_URL=https://grimoi.red
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

View File

@@ -4,7 +4,7 @@ import vue from '@astrojs/vue';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
site: process.env.SITE_URL || 'https://skills.here.run.place',
site: process.env.SITE_URL || 'https://grimoi.red',
output: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [vue()],

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
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

View File

@@ -1 +0,0 @@
const s=(t,r)=>{const o=t.__vccOpts||t;for(const[c,e]of r)o[c]=e;return o};export{s as _};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
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

Before

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

@@ -1,3 +0,0 @@
const onRequest = (_, next) => next();
export { onRequest };

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +0,0 @@
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,364 +0,0 @@
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';
const NOOP_MIDDLEWARE_FN = async (_ctx, next) => {
const response = await next();
response.headers.set(NOOP_MIDDLEWARE_HEADER, "true");
return response;
};
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": "https://skills.here.run.place", "SSR": true};
const ACTION_QUERY_PARAMS = ACTION_QUERY_PARAMS$1;
const codeToStatusMap = {
// Implemented from IANA HTTP Status Code Registry
// https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
PAYMENT_REQUIRED: 402,
FORBIDDEN: 403,
NOT_FOUND: 404,
METHOD_NOT_ALLOWED: 405,
NOT_ACCEPTABLE: 406,
PROXY_AUTHENTICATION_REQUIRED: 407,
REQUEST_TIMEOUT: 408,
CONFLICT: 409,
GONE: 410,
LENGTH_REQUIRED: 411,
PRECONDITION_FAILED: 412,
CONTENT_TOO_LARGE: 413,
URI_TOO_LONG: 414,
UNSUPPORTED_MEDIA_TYPE: 415,
RANGE_NOT_SATISFIABLE: 416,
EXPECTATION_FAILED: 417,
MISDIRECTED_REQUEST: 421,
UNPROCESSABLE_CONTENT: 422,
LOCKED: 423,
FAILED_DEPENDENCY: 424,
TOO_EARLY: 425,
UPGRADE_REQUIRED: 426,
PRECONDITION_REQUIRED: 428,
TOO_MANY_REQUESTS: 429,
REQUEST_HEADER_FIELDS_TOO_LARGE: 431,
UNAVAILABLE_FOR_LEGAL_REASONS: 451,
INTERNAL_SERVER_ERROR: 500,
NOT_IMPLEMENTED: 501,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
GATEWAY_TIMEOUT: 504,
HTTP_VERSION_NOT_SUPPORTED: 505,
VARIANT_ALSO_NEGOTIATES: 506,
INSUFFICIENT_STORAGE: 507,
LOOP_DETECTED: 508,
NETWORK_AUTHENTICATION_REQUIRED: 511
};
const statusToCodeMap = Object.entries(codeToStatusMap).reduce(
// reverse the key-value pairs
(acc, [key, value]) => ({ ...acc, [value]: key }),
{}
);
class ActionError extends Error {
type = "AstroActionError";
code = "INTERNAL_SERVER_ERROR";
status = 500;
constructor(params) {
super(params.message);
this.code = params.code;
this.status = ActionError.codeToStatus(params.code);
if (params.stack) {
this.stack = params.stack;
}
}
static codeToStatus(code) {
return codeToStatusMap[code];
}
static statusToCode(status) {
return statusToCodeMap[status] ?? "INTERNAL_SERVER_ERROR";
}
static fromJson(body) {
if (isInputError(body)) {
return new ActionInputError(body.issues);
}
if (isActionError(body)) {
return new ActionError(body);
}
return new ActionError({
code: "INTERNAL_SERVER_ERROR"
});
}
}
function isActionError(error) {
return typeof error === "object" && error != null && "type" in error && error.type === "AstroActionError";
}
function isInputError(error) {
return typeof error === "object" && error != null && "type" in error && error.type === "AstroActionInputError" && "issues" in error && Array.isArray(error.issues);
}
class ActionInputError extends ActionError {
type = "AstroActionInputError";
// We don't expose all ZodError properties.
// Not all properties will serialize from server to client,
// and we don't want to import the full ZodError object into the client.
issues;
fields;
constructor(issues) {
super({
message: `Failed to validate: ${JSON.stringify(issues, null, 2)}`,
code: "BAD_REQUEST"
});
this.issues = issues;
this.fields = {};
for (const issue of issues) {
if (issue.path.length > 0) {
const key = issue.path[0].toString();
this.fields[key] ??= [];
this.fields[key]?.push(issue.message);
}
}
}
}
function getActionQueryString(name) {
const searchParams = new URLSearchParams({ [ACTION_QUERY_PARAMS$1.actionName]: name });
return `?${searchParams.toString()}`;
}
function serializeActionResult(res) {
if (res.error) {
if (Object.assign(__vite_import_meta_env__, { _: process.env._ })?.DEV) {
actionResultErrorStack.set(res.error.stack);
}
let body2;
if (res.error instanceof ActionInputError) {
body2 = {
type: res.error.type,
issues: res.error.issues,
fields: res.error.fields
};
} else {
body2 = {
...res.error,
message: res.error.message
};
}
return {
type: "error",
status: res.error.status,
contentType: "application/json",
body: JSON.stringify(body2)
};
}
if (res.data === void 0) {
return {
type: "empty",
status: 204
};
}
let body;
try {
body = stringify(res.data, {
// Add support for URL objects
URL: (value) => value instanceof URL && value.href
});
} catch (e) {
let hint = ActionsReturnedInvalidDataError.hint;
if (res.data instanceof Response) {
hint = REDIRECT_STATUS_CODES.includes(res.data.status) ? "If you need to redirect when the action succeeds, trigger a redirect where the action is called. See the Actions guide for server and client redirect examples: https://docs.astro.build/en/guides/actions." : "If you need to return a Response object, try using a server endpoint instead. See https://docs.astro.build/en/guides/endpoints/#server-endpoints-api-routes";
}
throw new AstroError({
...ActionsReturnedInvalidDataError,
message: ActionsReturnedInvalidDataError.message(String(e)),
hint
});
}
return {
type: "data",
status: 200,
contentType: "application/json+devalue",
body
};
}
function deserializeActionResult(res) {
if (res.type === "error") {
let json;
try {
json = JSON.parse(res.body);
} catch {
return {
data: void 0,
error: new ActionError({
message: res.body,
code: "INTERNAL_SERVER_ERROR"
})
};
}
if (Object.assign(__vite_import_meta_env__, { _: process.env._ })?.PROD) {
return { error: ActionError.fromJson(json), data: void 0 };
} else {
const error = ActionError.fromJson(json);
error.stack = actionResultErrorStack.get();
return {
error,
data: void 0
};
}
}
if (res.type === "empty") {
return { data: void 0, error: void 0 };
}
return {
data: parse(res.body, {
URL: (href) => new URL(href)
}),
error: void 0
};
}
const actionResultErrorStack = /* @__PURE__ */ (function actionResultErrorStackFn() {
let errorStack;
return {
set(stack) {
errorStack = stack;
},
get() {
return errorStack;
}
};
})();
function template({
title,
pathname,
statusCode = 404,
tabTitle,
body
}) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${tabTitle}</title>
<style>
:root {
--gray-10: hsl(258, 7%, 10%);
--gray-20: hsl(258, 7%, 20%);
--gray-30: hsl(258, 7%, 30%);
--gray-40: hsl(258, 7%, 40%);
--gray-50: hsl(258, 7%, 50%);
--gray-60: hsl(258, 7%, 60%);
--gray-70: hsl(258, 7%, 70%);
--gray-80: hsl(258, 7%, 80%);
--gray-90: hsl(258, 7%, 90%);
--black: #13151A;
--accent-light: #E0CCFA;
}
* {
box-sizing: border-box;
}
html {
background: var(--black);
color-scheme: dark;
accent-color: var(--accent-light);
}
body {
background-color: var(--gray-10);
color: var(--gray-80);
font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace;
line-height: 1.5;
margin: 0;
}
a {
color: var(--accent-light);
}
.center {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
width: 100vw;
}
h1 {
margin-bottom: 8px;
color: white;
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-weight: 700;
margin-top: 1rem;
margin-bottom: 0;
}
.statusCode {
color: var(--accent-light);
}
.astro-icon {
height: 124px;
width: 124px;
}
pre, code {
padding: 2px 8px;
background: rgba(0,0,0, 0.25);
border: 1px solid rgba(255,255,255, 0.25);
border-radius: 4px;
font-size: 1.2em;
margin-top: 0;
max-width: 60em;
}
</style>
</head>
<body>
<main class="center">
<svg class="astro-icon" xmlns="http://www.w3.org/2000/svg" width="64" height="80" viewBox="0 0 64 80" fill="none"> <path d="M20.5253 67.6322C16.9291 64.3531 15.8793 57.4632 17.3776 52.4717C19.9755 55.6188 23.575 56.6157 27.3035 57.1784C33.0594 58.0468 38.7122 57.722 44.0592 55.0977C44.6709 54.7972 45.2362 54.3978 45.9045 53.9931C46.4062 55.4451 46.5368 56.9109 46.3616 58.4028C45.9355 62.0362 44.1228 64.8429 41.2397 66.9705C40.0868 67.8215 38.8669 68.5822 37.6762 69.3846C34.0181 71.8508 33.0285 74.7426 34.403 78.9491C34.4357 79.0516 34.4649 79.1541 34.5388 79.4042C32.6711 78.5705 31.3069 77.3565 30.2674 75.7604C29.1694 74.0757 28.6471 72.2121 28.6196 70.1957C28.6059 69.2144 28.6059 68.2244 28.4736 67.257C28.1506 64.8985 27.0406 63.8425 24.9496 63.7817C22.8036 63.7192 21.106 65.0426 20.6559 67.1268C20.6215 67.2865 20.5717 67.4446 20.5218 67.6304L20.5253 67.6322Z" fill="white"/> <path d="M20.5253 67.6322C16.9291 64.3531 15.8793 57.4632 17.3776 52.4717C19.9755 55.6188 23.575 56.6157 27.3035 57.1784C33.0594 58.0468 38.7122 57.722 44.0592 55.0977C44.6709 54.7972 45.2362 54.3978 45.9045 53.9931C46.4062 55.4451 46.5368 56.9109 46.3616 58.4028C45.9355 62.0362 44.1228 64.8429 41.2397 66.9705C40.0868 67.8215 38.8669 68.5822 37.6762 69.3846C34.0181 71.8508 33.0285 74.7426 34.403 78.9491C34.4357 79.0516 34.4649 79.1541 34.5388 79.4042C32.6711 78.5705 31.3069 77.3565 30.2674 75.7604C29.1694 74.0757 28.6471 72.2121 28.6196 70.1957C28.6059 69.2144 28.6059 68.2244 28.4736 67.257C28.1506 64.8985 27.0406 63.8425 24.9496 63.7817C22.8036 63.7192 21.106 65.0426 20.6559 67.1268C20.6215 67.2865 20.5717 67.4446 20.5218 67.6304L20.5253 67.6322Z" fill="url(#paint0_linear_738_686)"/> <path d="M0 51.6401C0 51.6401 10.6488 46.4654 21.3274 46.4654L29.3786 21.6102C29.6801 20.4082 30.5602 19.5913 31.5538 19.5913C32.5474 19.5913 33.4275 20.4082 33.7289 21.6102L41.7802 46.4654C54.4274 46.4654 63.1076 51.6401 63.1076 51.6401C63.1076 51.6401 45.0197 2.48776 44.9843 2.38914C44.4652 0.935933 43.5888 0 42.4073 0H20.7022C19.5206 0 18.6796 0.935933 18.1251 2.38914C18.086 2.4859 0 51.6401 0 51.6401Z" fill="white"/> <defs> <linearGradient id="paint0_linear_738_686" x1="31.554" y1="75.4423" x2="39.7462" y2="48.376" gradientUnits="userSpaceOnUse"> <stop stop-color="#D83333"/> <stop offset="1" stop-color="#F041FF"/> </linearGradient> </defs> </svg>
<h1>${statusCode ? `<span class="statusCode">${statusCode}: </span> ` : ""}<span class="statusMessage">${title}</span></h1>
${body || `
<pre>Path: ${escape(pathname)}</pre>
`}
</main>
</body>
</html>`;
}
const DEFAULT_404_ROUTE = {
component: DEFAULT_404_COMPONENT,
generate: () => "",
params: [],
pattern: /^\/404\/?$/,
prerender: false,
pathname: "/404",
segments: [[{ content: "404", dynamic: false, spread: false }]],
type: "page",
route: "/404",
fallbackRoutes: [],
isIndex: false,
origin: "internal"
};
function ensure404Route(manifest) {
if (!manifest.routes.some((route) => route.route === "/404")) {
manifest.routes.push(DEFAULT_404_ROUTE);
}
return manifest;
}
async function default404Page({ pathname }) {
return new Response(
template({
statusCode: 404,
title: "Not found",
tabTitle: "404: Not Found",
pathname
}),
{ status: 404, headers: { "Content-Type": "text/html" } }
);
}
default404Page.isAstroComponentFactory = true;
const default404Instance = {
default: default404Page
};
export { ActionError as A, DEFAULT_404_ROUTE as D, NOOP_MIDDLEWARE_FN as N, ACTION_RPC_ROUTE_PATTERN as a, ACTION_QUERY_PARAMS as b, default404Instance as c, deserializeActionResult as d, ensure404Route as e, getActionQueryString as g, serializeActionResult as s };

File diff suppressed because it is too large Load Diff

View File

@@ -1,157 +0,0 @@
import { promises, existsSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
function defineDriver(factory) {
return factory;
}
function createError(driver, message, opts) {
const err = new Error(`[unstorage] [${driver}] ${message}`, opts);
if (Error.captureStackTrace) {
Error.captureStackTrace(err, createError);
}
return err;
}
function createRequiredError(driver, name) {
if (Array.isArray(name)) {
return createError(
driver,
`Missing some of the required options ${name.map((n) => "`" + n + "`").join(", ")}`
);
}
return createError(driver, `Missing required option \`${name}\`.`);
}
function ignoreNotfound(err) {
return err.code === "ENOENT" || err.code === "EISDIR" ? null : err;
}
function ignoreExists(err) {
return err.code === "EEXIST" ? null : err;
}
async function writeFile(path, data, encoding) {
await ensuredir(dirname(path));
return promises.writeFile(path, data, encoding);
}
function readFile(path, encoding) {
return promises.readFile(path, encoding).catch(ignoreNotfound);
}
function unlink(path) {
return promises.unlink(path).catch(ignoreNotfound);
}
function readdir(dir) {
return promises.readdir(dir, { withFileTypes: true }).catch(ignoreNotfound).then((r) => r || []);
}
async function ensuredir(dir) {
if (existsSync(dir)) {
return;
}
await ensuredir(dirname(dir)).catch(ignoreExists);
await promises.mkdir(dir).catch(ignoreExists);
}
async function readdirRecursive(dir, ignore, maxDepth) {
if (ignore && ignore(dir)) {
return [];
}
const entries = await readdir(dir);
const files = [];
await Promise.all(
entries.map(async (entry) => {
const entryPath = resolve(dir, entry.name);
if (entry.isDirectory()) {
if (maxDepth === void 0 || maxDepth > 0) {
const dirFiles = await readdirRecursive(
entryPath,
ignore,
maxDepth === void 0 ? void 0 : maxDepth - 1
);
files.push(...dirFiles.map((f) => entry.name + "/" + f));
}
} else {
if (!(ignore && ignore(entry.name))) {
files.push(entry.name);
}
}
})
);
return files;
}
async function rmRecursive(dir) {
const entries = await readdir(dir);
await Promise.all(
entries.map((entry) => {
const entryPath = resolve(dir, entry.name);
if (entry.isDirectory()) {
return rmRecursive(entryPath).then(() => promises.rmdir(entryPath));
} else {
return promises.unlink(entryPath);
}
})
);
}
const PATH_TRAVERSE_RE = /\.\.:|\.\.$/;
const DRIVER_NAME = "fs-lite";
const fsLite = defineDriver((opts = {}) => {
if (!opts.base) {
throw createRequiredError(DRIVER_NAME, "base");
}
opts.base = resolve(opts.base);
const r = (key) => {
if (PATH_TRAVERSE_RE.test(key)) {
throw createError(
DRIVER_NAME,
`Invalid key: ${JSON.stringify(key)}. It should not contain .. segments`
);
}
const resolved = join(opts.base, key.replace(/:/g, "/"));
return resolved;
};
return {
name: DRIVER_NAME,
options: opts,
flags: {
maxDepth: true
},
hasItem(key) {
return existsSync(r(key));
},
getItem(key) {
return readFile(r(key), "utf8");
},
getItemRaw(key) {
return readFile(r(key));
},
async getMeta(key) {
const { atime, mtime, size, birthtime, ctime } = await promises.stat(r(key)).catch(() => ({}));
return { atime, mtime, size, birthtime, ctime };
},
setItem(key, value) {
if (opts.readOnly) {
return;
}
return writeFile(r(key), value, "utf8");
},
setItemRaw(key, value) {
if (opts.readOnly) {
return;
}
return writeFile(r(key), value);
},
removeItem(key) {
if (opts.readOnly) {
return;
}
return unlink(r(key));
},
getKeys(_base, topts) {
return readdirRecursive(r("."), opts.ignore, topts?.maxDepth);
},
async clear() {
if (opts.readOnly || opts.noClear) {
return;
}
await rmRecursive(r("."));
}
};
});
export { fsLite as default };

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,188 +0,0 @@
function appendForwardSlash(path) {
return path.endsWith("/") ? path : path + "/";
}
function prependForwardSlash(path) {
return path[0] === "/" ? path : "/" + path;
}
const MANY_TRAILING_SLASHES = /\/{2,}$/g;
function collapseDuplicateTrailingSlashes(path, trailingSlash) {
if (!path) {
return path;
}
return path.replace(MANY_TRAILING_SLASHES, trailingSlash ? "/" : "") || "/";
}
function removeTrailingForwardSlash(path) {
return path.endsWith("/") ? path.slice(0, path.length - 1) : path;
}
function removeLeadingForwardSlash(path) {
return path.startsWith("/") ? path.substring(1) : path;
}
function trimSlashes(path) {
return path.replace(/^\/|\/$/g, "");
}
function isString(path) {
return typeof path === "string" || path instanceof String;
}
const INTERNAL_PREFIXES = /* @__PURE__ */ new Set(["/_", "/@", "/.", "//"]);
const JUST_SLASHES = /^\/{2,}$/;
function isInternalPath(path) {
return INTERNAL_PREFIXES.has(path.slice(0, 2)) && !JUST_SLASHES.test(path);
}
function joinPaths(...paths) {
return paths.filter(isString).map((path, i) => {
if (i === 0) {
return removeTrailingForwardSlash(path);
} else if (i === paths.length - 1) {
return removeLeadingForwardSlash(path);
} else {
return trimSlashes(path);
}
}).join("/");
}
function removeQueryString(path) {
const index = path.lastIndexOf("?");
return index > 0 ? path.substring(0, index) : path;
}
function isRemotePath(src) {
if (!src) return false;
const trimmed = src.trim();
if (!trimmed) return false;
let decoded = trimmed;
let previousDecoded = "";
let maxIterations = 10;
while (decoded !== previousDecoded && maxIterations > 0) {
previousDecoded = decoded;
try {
decoded = decodeURIComponent(decoded);
} catch {
break;
}
maxIterations--;
}
if (/^[a-zA-Z]:/.test(decoded)) {
return false;
}
if (decoded[0] === "/" && decoded[1] !== "/" && decoded[1] !== "\\") {
return false;
}
if (decoded[0] === "\\") {
return true;
}
if (decoded.startsWith("//")) {
return true;
}
try {
const url = new URL(decoded, "http://n");
if (url.username || url.password) {
return true;
}
if (decoded.includes("@") && !url.pathname.includes("@") && !url.search.includes("@")) {
return true;
}
if (url.origin !== "http://n") {
const protocol = url.protocol.toLowerCase();
if (protocol === "file:") {
return false;
}
return true;
}
if (URL.canParse(decoded)) {
return true;
}
return false;
} catch {
return true;
}
}
function isParentDirectory(parentPath, childPath) {
if (!parentPath || !childPath) {
return false;
}
if (parentPath.includes("://") || childPath.includes("://")) {
return false;
}
if (isRemotePath(parentPath) || isRemotePath(childPath)) {
return false;
}
if (parentPath.includes("..") || childPath.includes("..")) {
return false;
}
if (parentPath.includes("\0") || childPath.includes("\0")) {
return false;
}
const normalizedParent = appendForwardSlash(slash(parentPath).toLowerCase());
const normalizedChild = slash(childPath).toLowerCase();
if (normalizedParent === normalizedChild || normalizedParent === normalizedChild + "/") {
return false;
}
return normalizedChild.startsWith(normalizedParent);
}
function slash(path) {
return path.replace(/\\/g, "/");
}
function fileExtension(path) {
const ext = path.split(".").pop();
return ext !== path ? `.${ext}` : "";
}
const WITH_FILE_EXT = /\/[^/]+\.\w+$/;
function hasFileExtension(path) {
return WITH_FILE_EXT.test(path);
}
function matchPattern(url, remotePattern) {
return matchProtocol(url, remotePattern.protocol) && matchHostname(url, remotePattern.hostname, true) && matchPort(url, remotePattern.port) && matchPathname(url, remotePattern.pathname, true);
}
function matchPort(url, port) {
return !port || port === url.port;
}
function matchProtocol(url, protocol) {
return !protocol || protocol === url.protocol.slice(0, -1);
}
function matchHostname(url, hostname, allowWildcard = false) {
if (!hostname) {
return true;
} else if (!allowWildcard || !hostname.startsWith("*")) {
return hostname === url.hostname;
} else if (hostname.startsWith("**.")) {
const slicedHostname = hostname.slice(2);
return slicedHostname !== url.hostname && url.hostname.endsWith(slicedHostname);
} else if (hostname.startsWith("*.")) {
const slicedHostname = hostname.slice(1);
if (!url.hostname.endsWith(slicedHostname)) {
return false;
}
const subdomainWithDot = url.hostname.slice(0, -(slicedHostname.length - 1));
return subdomainWithDot.endsWith(".") && !subdomainWithDot.slice(0, -1).includes(".");
}
return false;
}
function matchPathname(url, pathname, allowWildcard = false) {
if (!pathname) {
return true;
} else if (!allowWildcard || !pathname.endsWith("*")) {
return pathname === url.pathname;
} else if (pathname.endsWith("/**")) {
const slicedPathname = pathname.slice(0, -2);
return slicedPathname !== url.pathname && url.pathname.startsWith(slicedPathname);
} else if (pathname.endsWith("/*")) {
const slicedPathname = pathname.slice(0, -1);
const additionalPathChunks = url.pathname.replace(slicedPathname, "").split("/").filter(Boolean);
return additionalPathChunks.length === 1;
}
return false;
}
function isRemoteAllowed(src, {
domains,
remotePatterns
}) {
if (!URL.canParse(src)) {
return false;
}
const url = new URL(src);
if (!["http:", "https:", "data:"].includes(url.protocol)) {
return false;
}
return domains.some((domain) => matchHostname(url, domain)) || remotePatterns.some((remotePattern) => matchPattern(url, remotePattern));
}
export { isRemotePath as a, isParentDirectory as b, appendForwardSlash as c, removeTrailingForwardSlash as d, isInternalPath as e, fileExtension as f, collapseDuplicateTrailingSlashes as g, hasFileExtension as h, isRemoteAllowed as i, joinPaths as j, matchPattern as m, prependForwardSlash as p, removeQueryString as r, slash as s, trimSlashes as t };

View File

@@ -1,101 +0,0 @@
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 = {
low: 25,
mid: 50,
high: 80,
max: 100
};
async function loadSharp() {
let sharpImport;
try {
sharpImport = (await import('sharp')).default;
} catch {
throw new AstroError(MissingSharp);
}
sharpImport.cache(false);
return sharpImport;
}
const fitMap = {
fill: "fill",
contain: "inside",
cover: "cover",
none: "outside",
"scale-down": "inside",
outside: "outside",
inside: "inside"
};
const sharpService = {
validateOptions: baseService.validateOptions,
getURL: baseService.getURL,
parseURL: baseService.parseURL,
getHTMLAttributes: baseService.getHTMLAttributes,
getSrcSet: baseService.getSrcSet,
async transform(inputBuffer, transformOptions, config) {
if (!sharp) sharp = await loadSharp();
const transform = transformOptions;
const kernel = config.service.config.kernel;
if (transform.format === "svg") return { data: inputBuffer, format: "svg" };
const result = sharp(inputBuffer, {
failOnError: false,
pages: -1,
limitInputPixels: config.service.config.limitInputPixels
});
result.rotate();
const { format } = await result.metadata();
const withoutEnlargement = Boolean(transform.fit);
if (transform.width && transform.height && transform.fit) {
const fit = fitMap[transform.fit] ?? "inside";
result.resize({
width: Math.round(transform.width),
height: Math.round(transform.height),
kernel,
fit,
position: transform.position,
withoutEnlargement
});
} else if (transform.height && !transform.width) {
result.resize({
height: Math.round(transform.height),
kernel,
withoutEnlargement
});
} else if (transform.width) {
result.resize({
width: Math.round(transform.width),
kernel,
withoutEnlargement
});
}
if (transform.background) {
result.flatten({ background: transform.background });
}
if (transform.format) {
let quality = void 0;
if (transform.quality) {
const parsedQuality = parseQuality(transform.quality);
if (typeof parsedQuality === "number") {
quality = parsedQuality;
} else {
quality = transform.quality in qualityTable ? qualityTable[transform.quality] : void 0;
}
}
if (transform.format === "webp" && format === "gif") {
result.webp({ quality: typeof quality === "number" ? quality : void 0, loop: 0 });
} else {
result.toFormat(transform.format, { quality });
}
}
const { data, info } = await result.toBuffer({ resolveWithObject: true });
const needsCopy = "buffer" in data && data.buffer instanceof SharedArrayBuffer;
return {
data: needsCopy ? new Uint8Array(data) : data,
format: info.format
};
}
};
var sharp_default = sharpService;
export { sharp_default as default };

View File

@@ -1,109 +0,0 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
const SKILLS_DIR = path.resolve(
process.env.SKILLS_DIR || "data/skills"
);
const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const MAX_SLUG_LENGTH = 64;
function isValidSlug(slug) {
return slug.length >= 2 && slug.length <= MAX_SLUG_LENGTH && SLUG_RE.test(slug);
}
function skillPath(slug) {
return path.join(SKILLS_DIR, `${slug}.md`);
}
function parseTools(val) {
if (Array.isArray(val)) return val.map(String);
if (typeof val === "string") return val.split(",").map((t) => t.trim()).filter(Boolean);
return [];
}
function parseSkill(slug, raw) {
const { data, content } = matter(raw);
return {
slug,
name: data.name || slug,
description: data.description || "",
"allowed-tools": parseTools(data["allowed-tools"] ?? data.allowedTools),
"argument-hint": data["argument-hint"] || "",
model: data.model || "",
"user-invocable": data["user-invocable"] !== false,
"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
};
}
async function listSkills() {
await fs.mkdir(SKILLS_DIR, { recursive: true });
const files = await fs.readdir(SKILLS_DIR);
const skills = [];
for (const file of files) {
if (!file.endsWith(".md")) continue;
const slug = file.replace(/\.md$/, "");
const raw = await fs.readFile(path.join(SKILLS_DIR, file), "utf-8");
const { content: _, raw: __, ...meta } = parseSkill(slug, raw);
skills.push(meta);
}
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");
return parseSkill(slug, raw);
} catch {
return null;
}
}
async function createSkill(slug, content) {
if (!isValidSlug(slug)) {
throw new Error(`Invalid slug: ${slug}`);
}
await fs.mkdir(SKILLS_DIR, { recursive: true });
const dest = skillPath(slug);
try {
await fs.access(dest);
throw new Error(`Skill already exists: ${slug}`);
} catch (err) {
if (err instanceof Error && err.message.startsWith("Skill already exists")) {
throw err;
}
}
await fs.writeFile(dest, content, "utf-8");
return parseSkill(slug, content);
}
async function updateSkill(slug, content) {
const dest = skillPath(slug);
try {
await fs.access(dest);
} catch {
throw new Error(`Skill not found: ${slug}`);
}
await fs.writeFile(dest, content, "utf-8");
return parseSkill(slug, content);
}
async function deleteSkill(slug) {
const dest = skillPath(slug);
try {
await fs.access(dest);
} catch {
throw new Error(`Skill not found: ${slug}`);
}
await fs.unlink(dest);
}
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 };

View File

@@ -1,46 +0,0 @@
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 };

View File

@@ -1,309 +0,0 @@
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,55 +0,0 @@
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 };

69
dist/server/entry.mjs vendored
View File

@@ -1,69 +0,0 @@
import { renderers } from './renderers.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/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/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, {
pageMap,
serverIslandMap,
renderers,
actions: () => import('./noop-entrypoint.mjs'),
middleware: () => import('./_noop-middleware.mjs')
});
const _args = {
"mode": "standalone",
"client": "file:///Users/alex/projects/skillit/dist/client/",
"server": "file:///Users/alex/projects/skillit/dist/server/",
"host": false,
"port": 4321,
"assets": "_astro",
"experimentalStaticHeaders": false
};
const _exports = createExports(_manifest, _args);
const handler = _exports['handler'];
const startServer = _exports['startServer'];
const options = _exports['options'];
const _start = 'start';
if (Object.prototype.hasOwnProperty.call(serverEntrypointModule, _start)) {
serverEntrypointModule[_start](_manifest, _args);
}
export { handler, options, pageMap, startServer };

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
const server = {};
export { server };

View File

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

View File

@@ -1,292 +0,0 @@
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_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: {},
authorEmail: {},
authorName: {},
authorHasToken: { type: Boolean }
},
setup(__props, { expose: __expose }) {
__expose();
const props = __props;
const deleting = ref(false);
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 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) {
const data = await res.json().catch(() => ({ error: "Failed to delete" }));
throw new Error(data.error || "Failed to delete");
}
window.location.href = "/";
} catch (err) {
error.value = err instanceof Error ? err.message : "Failed to delete skill.";
deleting.value = false;
}
}
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(`<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) => {
const ssrContext = useSSRContext();
(ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("src/components/DeleteButton.vue");
return _sfc_setup ? _sfc_setup(props, ctx) : void 0;
};
const DeleteButton = /* @__PURE__ */ _export_sfc(_sfc_main, [["ssrRender", _sfc_ssrRender]]);
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;
const { slug } = Astro2.params;
const skill = await getSkill(slug);
if (!skill) {
return Astro2.redirect("/");
}
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 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 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";
const $$url = "/[slug]";
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
default: $$slug,
file: $$file,
url: $$url
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,38 +0,0 @@
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_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("https://skills.here.run.place");
const $$Edit = createComponent(async ($$result, $$props, $$slots) => {
const Astro2 = $$result.createAstro($$Astro, $$props, $$slots);
Astro2.self = $$Edit;
const { slug } = Astro2.params;
const skill = await getSkill(slug);
if (!skill) {
return Astro2.redirect("/");
}
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();
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";
const $$url = "/[slug]/edit";
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
default: $$Edit,
file: $$file,
url: $$url
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,40 +0,0 @@
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 };

View File

@@ -1,40 +0,0 @@
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

@@ -1,55 +0,0 @@
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

@@ -1,40 +0,0 @@
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,78 +0,0 @@
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 () => {
const skills = await listSkills();
return new Response(JSON.stringify(skills), {
headers: { "Content-Type": "application/json" }
});
};
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 { slug, content } = body;
if (!slug || !content) {
return new Response(JSON.stringify({ error: "slug and content are required" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
if (!isValidSlug(slug)) {
return new Response(JSON.stringify({ error: "Invalid slug. Use lowercase alphanumeric and hyphens, 2-64 chars." }), {
status: 400,
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" }
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
if (message.includes("already exists")) {
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,
GET,
POST
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,102 +0,0 @@
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 }) => {
const skill = await getSkill(params.slug);
if (!skill) {
return new Response("Not found", { status: 404 });
}
return new Response(skill.raw, {
headers: { "Content-Type": "text/markdown; charset=utf-8" }
});
};
const PUT = async ({ params, request }) => {
let body;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
if (!body.content) {
return new Response(JSON.stringify({ error: "content is required" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
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";
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
}
};
const DELETE = 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 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" }
});
}
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
DELETE,
GET,
PUT
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,18 +0,0 @@
import { 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");
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

@@ -1,18 +0,0 @@
import { b as buildSyncScript } from '../../../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../../../renderers.mjs';
const GET = async ({ url }) => {
const script = await buildSyncScript(url.origin, ".claude/skills");
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

@@ -1,19 +0,0 @@
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, 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" }
});
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
GET
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,18 +0,0 @@
import { c as buildPushScript } from '../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../renderers.mjs';
const GET = async ({ url }) => {
const script = await buildPushScript(url.origin, "$HOME/.claude/skills");
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

@@ -1,18 +0,0 @@
import { b as buildSyncScript } from '../chunks/sync_BEq_wzpT.mjs';
export { renderers } from '../renderers.mjs';
const GET = async ({ url }) => {
const script = await buildSyncScript(url.origin, ".claude/skills");
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 };

File diff suppressed because one or more lines are too long

View File

@@ -1,37 +0,0 @@
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_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();
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";
const $$url = "/new";
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
default: $$New,
file: $$file,
url: $$url
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,19 +0,0 @@
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, 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" }
});
};
const _page = /*#__PURE__*/Object.freeze(/*#__PURE__*/Object.defineProperty({
__proto__: null,
GET
}, Symbol.toStringTag, { value: 'Module' }));
const page = () => _page;
export { page };

View File

@@ -1,81 +0,0 @@
import { defineComponent, h, createSSRApp } from 'vue';
import { renderToString } from 'vue/server-renderer';
const setup = () => {};
const contexts = /* @__PURE__ */ new WeakMap();
const ID_PREFIX = "s";
function getContext(rendererContextResult) {
if (contexts.has(rendererContextResult)) {
return contexts.get(rendererContextResult);
}
const ctx = {
currentIndex: 0,
get id() {
return ID_PREFIX + this.currentIndex.toString();
}
};
contexts.set(rendererContextResult, ctx);
return ctx;
}
function incrementId(rendererContextResult) {
const ctx = getContext(rendererContextResult);
const id = ctx.id;
ctx.currentIndex++;
return id;
}
const StaticHtml = defineComponent({
props: {
value: String,
name: String,
hydrate: {
type: Boolean,
default: true
}
},
setup({ name, value, hydrate }) {
if (!value) return () => null;
let tagName = hydrate ? "astro-slot" : "astro-static-slot";
return () => h(tagName, { name, innerHTML: value });
}
});
var static_html_default = StaticHtml;
async function check(Component) {
return !!Component["ssrRender"] || !!Component["__ssrInlineRender"];
}
async function renderToStaticMarkup(Component, inputProps, slotted, metadata) {
let prefix;
if (this && this.result) {
prefix = incrementId(this.result);
}
const attrs = { prefix };
const slots = {};
const props = { ...inputProps };
delete props.slot;
for (const [key, value] of Object.entries(slotted)) {
slots[key] = () => h(static_html_default, {
value,
name: key === "default" ? void 0 : key,
// Adjust how this is hydrated only when the version of Astro supports `astroStaticSlot`
hydrate: metadata?.astroStaticSlot ? !!metadata.hydrate : true
});
}
const app = createSSRApp({ render: () => h(Component, props, slots) });
app.config.idPrefix = prefix;
await setup();
const html = await renderToString(app);
return { html, attrs };
}
const renderer = {
name: "@astrojs/vue",
check,
renderToStaticMarkup,
supportsAstroStaticSlot: true
};
var server_default = renderer;
const renderers = [Object.assign({"name":"@astrojs/vue","clientEntrypoint":"@astrojs/vue/client.js","serverEntrypoint":"@astrojs/vue/server.js"}, { ssr: server_default }),];
export { renderers };

18
grimoired.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,8 +0,0 @@
{
"hash": "867abd67",
"configHash": "8be89d65",
"lockfileHash": "e3b0c442",
"browserHash": "88ab680e",
"optimized": {},
"chunks": {}
}

View File

@@ -1,3 +0,0 @@
{
"type": "module"
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

18
public/grimoired.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -11,13 +11,13 @@
{{ deleting ? 'Deleting...' : 'Delete' }}
</button>
<!-- Token modal for protected skills -->
<!-- Token modal for protected resources -->
<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>
<h3 class="text-lg font-semibold text-red-400 mb-1">Delete Resource</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.
This resource is owned by <strong class="text-gray-300">{{ authorName || authorEmail }}</strong>. Enter your token to delete it.
</p>
<form @submit.prevent="verifyAndDelete">
@@ -65,8 +65,11 @@ const props = defineProps<{
authorEmail?: string;
authorName?: string;
authorHasToken?: boolean;
resourceType?: string;
}>();
const type = props.resourceType || 'skills';
const deleting = ref(false);
const showModal = ref(false);
const token = ref('');
@@ -75,8 +78,7 @@ const tokenInput = ref<HTMLInputElement>();
async function handleClick() {
if (props.authorEmail && props.authorHasToken) {
// Try saved token first
const saved = localStorage.getItem('skillshere-token') || '';
const saved = localStorage.getItem('grimoired-token') || '';
if (saved) {
try {
const res = await fetch('/api/auth/verify', {
@@ -100,7 +102,6 @@ async function handleClick() {
}
async function verifyAndDelete() {
// Verify token first
error.value = '';
deleting.value = true;
@@ -123,7 +124,7 @@ async function verifyAndDelete() {
return;
}
localStorage.setItem('skillshere-token', token.value);
localStorage.setItem('grimoired-token', token.value);
doDelete(token.value);
}
@@ -142,7 +143,7 @@ async function doDelete(authToken: string) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const res = await fetch(`/api/skills/${props.slug}`, { method: 'DELETE', headers });
const res = await fetch(`/api/resources/${type}/${props.slug}`, { method: 'DELETE', headers });
if (res.status === 403) {
const data = await res.json();
error.value = data.error || 'Permission denied';
@@ -156,7 +157,7 @@ async function doDelete(authToken: string) {
}
window.location.href = '/';
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to delete skill.';
error.value = err instanceof Error ? err.message : 'Failed to delete.';
deleting.value = false;
}
}

View File

@@ -16,7 +16,7 @@
<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.
This resource is owned by <strong class="text-gray-300">{{ authorName || authorEmail }}</strong>. Enter your token to edit.
</p>
<form @submit.prevent="verify">
@@ -44,7 +44,7 @@
<button
type="button"
@click="forkSkill"
@click="forkResource"
class="text-sm text-[var(--color-accent-400)] hover:text-[var(--color-accent-300)] transition-colors"
>
Fork instead
@@ -72,8 +72,11 @@ const props = defineProps<{
authorEmail?: string;
authorName?: string;
authorHasToken?: boolean;
resourceType?: string;
}>();
const type = props.resourceType || 'skills';
const showModal = ref(false);
const token = ref('');
const error = ref('');
@@ -82,12 +85,11 @@ const tokenInput = ref<HTMLInputElement>();
async function handleClick() {
if (!props.authorEmail || !props.authorHasToken) {
window.location.href = `/${props.slug}/edit`;
window.location.href = `/${type}/${props.slug}/edit`;
return;
}
// Try saved token first
const saved = localStorage.getItem('skillshere-token') || '';
const saved = localStorage.getItem('grimoired-token') || '';
if (saved) {
try {
const res = await fetch('/api/auth/verify', {
@@ -96,8 +98,8 @@ async function handleClick() {
body: JSON.stringify({ email: props.authorEmail, token: saved }),
});
if (res.ok) {
localStorage.setItem('skillshere-token', saved);
window.location.href = `/${props.slug}/edit`;
localStorage.setItem('grimoired-token', saved);
window.location.href = `/${type}/${props.slug}/edit`;
return;
}
} catch { /* fall through to modal */ }
@@ -126,9 +128,8 @@ async function verify() {
return;
}
// Store token for the editor to use
localStorage.setItem('skillshere-token', token.value);
window.location.href = `/${props.slug}/edit`;
localStorage.setItem('grimoired-token', token.value);
window.location.href = `/${type}/${props.slug}/edit`;
} catch {
error.value = 'Could not verify token';
} finally {
@@ -136,8 +137,8 @@ async function verify() {
}
}
function forkSkill() {
function forkResource() {
showModal.value = false;
window.location.href = `/new?from=${encodeURIComponent(props.slug)}`;
window.location.href = `/${type}/new?from=${encodeURIComponent(props.slug)}`;
}
</script>

View File

@@ -0,0 +1,267 @@
<template>
<!-- Text input -->
<div v-if="field.type === 'text'">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">{{ field.label }}</label>
<input
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
type="text"
:placeholder="field.placeholder || ''"
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 v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
</div>
<!-- Number input -->
<div v-else-if="field.type === 'number'">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">{{ field.label }}</label>
<input
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
type="number"
:placeholder="field.placeholder || ''"
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 v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
</div>
<!-- Select -->
<div v-else-if="field.type === 'select'">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">{{ field.label }}</label>
<select
:value="modelValue"
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
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 v-if="field.dynamicOptions === 'models'" value="">Default</option>
<option v-for="opt in resolvedOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<p v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
</div>
<!-- Toggle -->
<div v-else-if="field.type === 'toggle'">
<label class="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
:checked="Boolean(modelValue)"
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
class="sr-only peer"
/>
<div class="h-5 w-9 rounded-full bg-white/[0.06] border border-white/[0.06] peer-checked:bg-[var(--color-accent-500)] peer-checked:border-[var(--color-accent-500)] relative transition-all after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:h-4 after:w-4 after:rounded-full after:bg-gray-400 after:transition-all peer-checked:after:translate-x-4 peer-checked:after:bg-white"></div>
<span class="text-xs text-gray-500 group-hover:text-gray-300 transition-colors">
{{ field.label }}
<span v-if="field.hint" class="text-gray-600">({{ field.hint }})</span>
</span>
</label>
</div>
<!-- Toggle grid (tools, etc) -->
<div v-else-if="field.type === 'toggle-grid'">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">{{ field.label }}</label>
<div class="flex flex-wrap gap-1.5 rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-3 py-2.5 min-h-[42px]">
<button
v-for="opt in gridOptions"
:key="opt"
type="button"
@click="toggleGridItem(opt)"
:class="[
'rounded-md px-2.5 py-1 text-xs font-medium transition-all',
selectedSet.has(opt)
? 'bg-[var(--color-accent-500)] text-white shadow-sm'
: 'bg-white/[0.04] border border-white/[0.06] text-gray-500 hover:text-gray-300 hover:bg-white/[0.08]'
]"
>
{{ opt }}
</button>
</div>
<p v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
</div>
<!-- JSON editor -->
<div v-else-if="field.type === 'json'">
<details class="group">
<summary class="text-xs font-medium uppercase tracking-wider text-gray-500 cursor-pointer hover:text-gray-400 transition-colors">{{ field.label }} (advanced)</summary>
<div class="mt-3">
<textarea
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
rows="4"
:placeholder="field.placeholder || ''"
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-3 font-mono text-xs text-white placeholder-gray-700 focus:border-[var(--color-accent-500)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-500)]/20 transition-all resize-y"
/>
<p v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
</div>
</details>
</div>
<!-- Tags input -->
<div v-else-if="field.type === 'tags'" class="relative">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">{{ field.label }}</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="tagInputRef?.focus()"
>
<span
v-for="(tag, i) in tagsList"
: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="removeTagItem(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="tagInputRef"
v-model="tagInput"
type="text"
:placeholder="field.placeholder || 'Add item...'"
class="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-gray-600 outline-none"
@keydown="onTagKeydown"
@focus="tagSuggestionsOpen = true"
@input="tagSuggestionsOpen = true"
@blur="onTagBlur"
/>
</div>
<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"
>
{{ s }}
</button>
</div>
<p v-if="field.hint" class="mt-1.5 text-xs text-gray-600">{{ field.hint }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
interface FieldDef {
key: string;
label: string;
type: 'text' | 'select' | 'toggle-grid' | 'toggle' | 'number' | 'json' | 'tags';
placeholder?: string;
hint?: string;
options?: Array<{ value: string; label: string }>;
dynamicOptions?: 'tools' | 'models' | 'skills';
defaultValue?: unknown;
}
const props = defineProps<{
field: FieldDef;
modelValue: unknown;
tools?: string[];
models?: Array<{ id: string; display_name: string }>;
skills?: string[];
}>();
const emit = defineEmits<{
'update:modelValue': [value: unknown];
}>();
// --- Select options ---
const resolvedOptions = computed(() => {
if (props.field.dynamicOptions === 'models' && props.models) {
return props.models.map(m => ({ value: m.id, label: m.display_name }));
}
return props.field.options || [];
});
// --- Toggle grid ---
const gridOptions = computed(() => {
if (props.field.dynamicOptions === 'tools' && props.tools) {
return props.tools;
}
return (props.field.options || []).map(o => o.value);
});
const selectedSet = computed(() => {
const val = props.modelValue;
if (val instanceof Set) return val as Set<string>;
if (Array.isArray(val)) return new Set(val as string[]);
if (typeof val === 'string') return new Set(val.split(',').map(t => t.trim()).filter(Boolean));
return new Set<string>();
});
function toggleGridItem(item: string) {
const current = new Set(selectedSet.value);
if (current.has(item)) {
current.delete(item);
} else {
current.add(item);
}
emit('update:modelValue', [...current]);
}
// --- Tags input ---
const tagInput = ref('');
const tagInputRef = ref<HTMLInputElement>();
const tagSuggestionsOpen = ref(false);
const tagsList = computed(() => {
const val = props.modelValue;
if (Array.isArray(val)) return val as string[];
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
return [];
});
const knownTagOptions = computed(() => {
if (props.field.dynamicOptions === 'skills' && props.skills) return props.skills;
return [];
});
const tagSuggestions = computed(() => {
if (knownTagOptions.value.length === 0) return [];
const q = tagInput.value.toLowerCase().trim();
const current = new Set(tagsList.value.map(t => t.toLowerCase()));
return knownTagOptions.value.filter(t =>
!current.has(t.toLowerCase()) && (!q || t.toLowerCase().includes(q))
);
});
function addTagItem(val: string) {
const trimmed = val.trim();
if (trimmed && !tagsList.value.includes(trimmed)) {
emit('update:modelValue', [...tagsList.value, trimmed]);
}
tagInput.value = '';
tagInputRef.value?.focus();
}
function removeTagItem(idx: number) {
const list = [...tagsList.value];
list.splice(idx, 1);
emit('update:modelValue', list);
}
let tagBlurTimer: ReturnType<typeof setTimeout>;
function onTagBlur() {
tagBlurTimer = setTimeout(() => { tagSuggestionsOpen.value = false; }, 200);
}
function onTagSuggestionClick(tag: string) {
clearTimeout(tagBlurTimer);
addTagItem(tag);
}
function onTagKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTagItem(tagInput.value);
} else if (e.key === 'Backspace' && !tagInput.value && tagsList.value.length) {
const list = [...tagsList.value];
list.pop();
emit('update:modelValue', list);
}
}
</script>

View File

@@ -0,0 +1,357 @@
<template>
<div class="rounded-xl border border-white/[0.06] bg-[var(--color-surface-50)] p-4 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-white">Files</h3>
<div class="flex gap-1.5">
<button
type="button"
@click="addMode = addMode === 'create' ? '' : 'create'"
:class="['inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all',
addMode === 'create'
? 'bg-[var(--color-accent-500)]/15 border-[var(--color-accent-500)]/30 text-[var(--color-accent-400)]'
: 'bg-white/[0.06] border-white/[0.06] text-gray-400 hover:text-white hover:bg-white/[0.1]']"
>
<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>
Create
</button>
<button
type="button"
@click="addMode = addMode === 'upload' ? '' : 'upload'"
:class="['inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all',
addMode === 'upload'
? 'bg-[var(--color-accent-500)]/15 border-[var(--color-accent-500)]/30 text-[var(--color-accent-400)]'
: 'bg-white/[0.06] border-white/[0.06] text-gray-400 hover:text-white hover:bg-white/[0.1]']"
>
<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>
Upload
</button>
</div>
</div>
<!-- Create inline file -->
<div v-if="addMode === 'create'" class="rounded-lg border border-white/[0.08] bg-[var(--color-surface-100)] p-3 space-y-3">
<div class="flex items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Directory</label>
<select v-model="dir" class="rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm text-white focus:outline-none transition-all">
<option value="scripts">scripts/ executable code</option>
<option value="references">references/ docs &amp; context</option>
<option value="assets">assets/ templates &amp; files</option>
</select>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-500 mb-1">File name</label>
<input
v-model="newFileName"
type="text"
placeholder="run.sh"
class="w-full rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm font-mono text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none transition-all"
@keydown.enter.prevent="createInlineFile"
/>
</div>
<button type="button" @click="createInlineFile" :disabled="!newFileName.trim() || saving" class="rounded-lg bg-[var(--color-accent-500)] px-4 py-2 text-xs font-semibold text-white hover:bg-[var(--color-accent-600)] disabled:opacity-50 transition-all">Create</button>
</div>
<p class="text-[11px] text-gray-600"><strong class="text-gray-500">scripts/</strong> run via hooks or tool calls &middot; <strong class="text-gray-500">references/</strong> Claude reads as context &middot; <strong class="text-gray-500">assets/</strong> copied into the project</p>
<span v-if="createError" class="text-xs text-red-400">{{ createError }}</span>
</div>
<!-- Upload file -->
<div v-if="addMode === 'upload'" class="rounded-lg border border-white/[0.08] bg-[var(--color-surface-100)] p-3 space-y-3">
<div class="flex items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Directory</label>
<select v-model="dir" class="rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm text-white focus:outline-none transition-all">
<option value="scripts">scripts/ executable code</option>
<option value="references">references/ docs &amp; context</option>
<option value="assets">assets/ templates &amp; files</option>
</select>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-500 mb-1">File</label>
<input
ref="fileInput"
type="file"
@change="onFileSelected"
class="w-full text-sm text-gray-400 file:mr-2 file:rounded-lg file:border-0 file:bg-white/[0.06] file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-gray-300 hover:file:bg-white/[0.1] file:cursor-pointer file:transition-all"
/>
</div>
<button type="button" @click="uploadFile" :disabled="!selectedFile || saving" class="rounded-lg bg-[var(--color-accent-500)] px-4 py-2 text-xs font-semibold text-white hover:bg-[var(--color-accent-600)] disabled:opacity-50 transition-all">{{ saving ? 'Uploading...' : 'Upload' }}</button>
</div>
<span v-if="uploadError" class="text-xs text-red-400">{{ uploadError }}</span>
</div>
<!-- File list -->
<div v-if="fileList.length > 0" class="space-y-2">
<div v-for="f in fileList" :key="f.relativePath" class="rounded-lg border border-white/[0.06] overflow-hidden">
<!-- File header -->
<div class="flex items-center gap-2 px-3 py-2 bg-white/[0.02] hover:bg-white/[0.04] transition-colors group">
<button v-if="isTextFile(f.relativePath)" type="button" @click="toggleExpand(f.relativePath)" class="shrink-0 text-gray-600 hover:text-gray-400 transition-colors">
<svg :class="['h-3.5 w-3.5 transition-transform', expanded[f.relativePath] ? 'rotate-90' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</button>
<svg v-else class="h-3.5 w-3.5 shrink-0 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
<span class="flex-1 text-sm text-gray-400 truncate font-mono">{{ f.relativePath }}</span>
<span class="shrink-0 text-xs text-gray-600">{{ formatSize(f.size) }}</span>
<button
type="button"
@click="deleteFile(f.relativePath)"
class="shrink-0 opacity-0 group-hover:opacity-100 rounded p-1 text-gray-600 hover:text-red-400 hover:bg-red-400/10 transition-all"
title="Delete file"
>
<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>
</button>
</div>
<!-- Inline editor for text files -->
<div v-if="isTextFile(f.relativePath) && expanded[f.relativePath]" class="border-t border-white/[0.06]">
<div v-if="fileContents[f.relativePath] === undefined" class="px-4 py-3 text-xs text-gray-600">Loading...</div>
<template v-else>
<textarea
v-model="fileContents[f.relativePath]"
rows="10"
:placeholder="filePlaceholder(f.relativePath)"
class="w-full bg-[var(--color-surface-100)] px-4 py-3 font-mono text-xs text-white placeholder-gray-600 focus:outline-none resize-y leading-relaxed"
/>
<div class="flex items-center gap-2 px-3 py-2 border-t border-white/[0.06] bg-white/[0.02]">
<button
type="button"
@click="saveFileContent(f.relativePath)"
:disabled="savingFile === f.relativePath"
class="rounded-lg bg-[var(--color-accent-500)] px-3 py-1 text-xs font-semibold text-white hover:bg-[var(--color-accent-600)] disabled:opacity-50 transition-all"
>{{ savingFile === f.relativePath ? 'Saving...' : 'Save' }}</button>
<span v-if="fileSaveStatus[f.relativePath]" class="text-xs" :class="fileSaveStatus[f.relativePath] === 'saved' ? 'text-emerald-400' : 'text-red-400'">
{{ fileSaveStatus[f.relativePath] === 'saved' ? 'Saved' : fileSaveStatus[f.relativePath] }}
</span>
</div>
</template>
</div>
</div>
</div>
<p v-else class="text-xs text-gray-600">No files yet. Create scripts, docs, or upload assets.</p>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
interface FileEntry {
relativePath: string;
size: number;
}
const BINARY_EXTENSIONS = new Set([
'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'svg', 'bmp', 'tiff',
'zip', 'gz', 'tar', 'bz2', 'xz', '7z', 'rar',
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
'woff', 'woff2', 'ttf', 'otf', 'eot',
'mp3', 'mp4', 'wav', 'ogg', 'webm', 'avi', 'mov',
'exe', 'dll', 'so', 'dylib', 'bin', 'dat', 'wasm',
]);
function isTextFile(name: string): boolean {
const ext = name.split('.').pop()?.toLowerCase() || '';
return !BINARY_EXTENSIONS.has(ext);
}
function filePlaceholder(filePath: string): string {
const dir = filePath.split('/')[0];
const fname = filePath.split('/').pop() || '';
if (dir === 'scripts') {
return `#!/usr/bin/env bash\n# Executable code that Claude runs via hooks or tool calls.\n# Example: a linter wrapper, a code generator, a deploy helper.\n\necho "Running ${fname}..."`;
}
if (dir === 'references') {
return `# ${fname}\n\nReference documentation that Claude reads for context.\nPut API docs, style guides, architecture notes, or\nany material Claude should consult while using this skill.`;
}
if (dir === 'assets') {
return `# ${fname}\n\nStatic resources: templates, config files, schemas,\nor any files the skill copies into the project.\nExample: a .eslintrc template, a Dockerfile, a JSON schema.`;
}
return `Write ${fname} content...`;
}
const props = defineProps<{
resourceType: string;
slug: string;
}>();
const fileList = ref<FileEntry[]>([]);
const addMode = ref<'' | 'create' | 'upload'>('');
const dir = ref('scripts');
const newFileName = ref('');
const selectedFile = ref<File | null>(null);
const saving = ref(false);
const uploadError = ref('');
const createError = ref('');
const fileInput = ref<HTMLInputElement>();
// Inline editing state
const expanded = reactive<Record<string, boolean>>({});
const fileContents = reactive<Record<string, string>>({});
const savingFile = ref('');
const fileSaveStatus = reactive<Record<string, string>>({});
const apiBase = `/api/resources/${props.resourceType}/${props.slug}/files`;
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getAuthHeaders(): Record<string, string> {
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('grimoired-token') || '' : '';
if (token) return { 'Authorization': `Bearer ${token}` };
return {};
}
async function loadFiles() {
try {
const res = await fetch(apiBase);
if (res.ok) {
const data = await res.json();
fileList.value = data.files || [];
}
} catch { /* ignore */ }
}
async function toggleExpand(path: string) {
if (expanded[path]) {
expanded[path] = false;
return;
}
expanded[path] = true;
// Load content if not yet loaded
if (fileContents[path] === undefined) {
try {
const res = await fetch(`${apiBase}/${path}`);
if (res.ok) {
fileContents[path] = await res.text();
} else {
fileContents[path] = '';
}
} catch {
fileContents[path] = '';
}
}
}
async function saveFileContent(path: string) {
savingFile.value = path;
delete fileSaveStatus[path];
try {
const body = new Blob([fileContents[path]], { type: 'text/plain' });
const res = await fetch(`${apiBase}/${path}`, {
method: 'PUT',
headers: getAuthHeaders(),
body,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Save failed');
}
fileSaveStatus[path] = 'saved';
setTimeout(() => { delete fileSaveStatus[path]; }, 2000);
await loadFiles();
} catch (err) {
fileSaveStatus[path] = err instanceof Error ? err.message : 'Save failed';
} finally {
savingFile.value = '';
}
}
async function createInlineFile() {
const fname = newFileName.value.trim();
if (!fname) return;
saving.value = true;
createError.value = '';
const path = `${dir.value}/${fname}`;
try {
const body = new Blob([''], { type: 'text/plain' });
const res = await fetch(`${apiBase}/${path}`, {
method: 'PUT',
headers: getAuthHeaders(),
body,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Create failed');
}
newFileName.value = '';
addMode.value = '';
await loadFiles();
// Auto-expand the new file
fileContents[path] = '';
expanded[path] = true;
} catch (err) {
createError.value = err instanceof Error ? err.message : 'Create failed';
} finally {
saving.value = false;
}
}
function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
selectedFile.value = input.files?.[0] || null;
}
async function uploadFile() {
if (!selectedFile.value) return;
saving.value = true;
uploadError.value = '';
try {
const formData = new FormData();
formData.append('file', selectedFile.value);
formData.append('path', `${dir.value}/${selectedFile.value.name}`);
const res = await fetch(apiBase, {
method: 'POST',
headers: getAuthHeaders(),
body: formData,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Upload failed');
}
selectedFile.value = null;
if (fileInput.value) fileInput.value.value = '';
addMode.value = '';
await loadFiles();
} catch (err) {
uploadError.value = err instanceof Error ? err.message : 'Upload failed';
} finally {
saving.value = false;
}
}
async function deleteFile(relativePath: string) {
if (!confirm(`Delete ${relativePath}?`)) return;
try {
const res = await fetch(`${apiBase}/${relativePath}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Delete failed');
}
delete expanded[relativePath];
delete fileContents[relativePath];
await loadFiles();
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed');
}
}
onMounted(loadFiles);
</script>

View File

@@ -0,0 +1,98 @@
---
interface FileEntry {
relativePath: string;
size: number;
}
interface Props {
files: FileEntry[];
slug: string;
type: string;
mainFileName: string;
mainFileSize: number;
}
const { files, slug, type, mainFileName, mainFileSize } = Astro.props;
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const DIR_HINTS: Record<string, string> = {
scripts: 'executable code',
references: 'docs & context',
assets: 'templates & files',
};
// Group files by top-level directory
const groups: Record<string, FileEntry[]> = {};
for (const f of files) {
const dir = f.relativePath.split('/')[0];
if (!groups[dir]) groups[dir] = [];
groups[dir].push(f);
}
---
<div class="space-y-2">
<!-- Main file -->
<button data-file-main class="tree-item active flex items-center gap-2 py-1 text-sm w-full text-left rounded-md px-1.5 -mx-1.5">
<svg class="h-4 w-4 shrink-0 text-[var(--color-accent-500)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
<span class="font-medium text-[var(--color-accent-400)]">{mainFileName}</span>
<span class="text-xs text-gray-600">{formatSize(mainFileSize)}</span>
<span class="rounded px-1.5 py-0.5 text-[10px] font-medium bg-[var(--color-accent-500)]/10 text-[var(--color-accent-500)]">main</span>
</button>
<!-- Subdirectory groups -->
{Object.entries(groups).map(([dir, entries]) => (
<details open class="group">
<summary class="flex items-center gap-2 cursor-pointer select-none text-sm text-gray-400 hover:text-gray-200 transition-colors py-1">
<svg class="h-4 w-4 text-gray-600 group-open:rotate-90 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
<svg class="h-4 w-4 text-[var(--color-accent-500)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" />
</svg>
<span class="font-medium">{dir}/</span>
{DIR_HINTS[dir] && <span class="text-xs text-gray-600">— {DIR_HINTS[dir]}</span>}
<span class="text-xs text-gray-600 ml-auto">{entries.length} file{entries.length !== 1 ? 's' : ''}</span>
</summary>
<ul class="ml-6 mt-1 space-y-0.5 border-l border-white/[0.06] pl-3">
{entries.map((f) => {
const fileName = f.relativePath.split('/').slice(1).join('/');
const downloadUrl = `/api/resources/${type}/${slug}/files/${f.relativePath}`;
return (
<li>
<button data-file-path={f.relativePath} class="tree-item flex items-center gap-2 py-0.5 text-sm w-full text-left rounded-md px-1.5 -mx-1.5">
<svg class="h-3.5 w-3.5 shrink-0 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
<span class="text-gray-400 truncate" title={f.relativePath}>{fileName}</span>
<span class="shrink-0 text-xs text-gray-600">{formatSize(f.size)}</span>
</button>
</li>
);
})}
</ul>
</details>
))}
</div>
<style>
.tree-item {
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.tree-item:hover {
background: rgba(255,255,255,0.04);
}
.tree-item.active {
background: rgba(255,255,255,0.06);
}
.tree-item.active span:first-of-type {
color: var(--color-accent-400);
}
</style>

View File

@@ -0,0 +1,106 @@
---
interface Props {
resourceType: string;
slug: string;
name: string;
description: string;
tags?: string[];
author?: string;
forkCount?: number;
downloads?: number;
pushes?: number;
lastPushedAt?: string | null;
typeLabel: string;
typeColor: string;
/** For skills: allowed tools badges */
tools?: string[];
}
const {
resourceType,
slug,
name,
description,
tags = [],
author,
forkCount = 0,
downloads = 0,
pushes = 0,
lastPushedAt,
typeLabel,
typeColor,
tools = [],
} = 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;
---
<a
href={`/${resourceType}/${slug}`}
class="group relative block rounded-2xl border border-white/[0.06] bg-surface-100 p-6 hover:border-accent-500/30 hover:bg-surface-200/80 transition-all duration-300"
>
<div class="absolute inset-0 rounded-2xl bg-gradient-to-br from-accent-500/[0.03] to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<div class="relative">
<div class="flex items-start justify-between mb-2">
<div class="flex items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-[10px] font-semibold" style={`background: ${typeColor}20; color: ${typeColor}; border: 1px solid ${typeColor}40;`}>
{typeLabel}
</span>
<h2 class="text-[15px] font-semibold text-white group-hover:text-accent-400 transition-colors">{name}</h2>
</div>
<svg class="h-4 w-4 text-gray-600 group-hover:text-accent-500 group-hover:translate-x-0.5 transition-all shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<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-3">{truncated}</p>}
{tools.length > 0 && (
<div class="flex flex-wrap gap-1.5 mb-3">
{tools.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}
</span>
))}
</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

@@ -0,0 +1,732 @@
<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>
<!-- Shared fields: Name + Description -->
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Name</label>
<input
v-model="name"
type="text"
required
maxlength="64"
placeholder="My Awesome Resource"
: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 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>
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Description</label>
<input
v-model="description"
type="text"
maxlength="200"
placeholder="Brief description of what this 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 (shared) -->
<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>
<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>
<!-- Format toggle (create mode only) -->
<div v-if="mode === 'create' && !isFork">
<label class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Format</label>
<div class="flex rounded-xl border border-white/[0.06] overflow-hidden w-fit">
<button
type="button"
@click="format = 'file'"
:class="['px-4 py-2 text-sm font-medium transition-all', format === 'file' ? 'bg-[var(--color-accent-500)]/15 text-[var(--color-accent-400)] border-r border-white/[0.06]' : 'text-gray-500 hover:text-gray-300 border-r border-white/[0.06]']"
>Simple (.md)</button>
<button
type="button"
@click="format = 'folder'"
:class="['px-4 py-2 text-sm font-medium transition-all', format === 'folder' ? 'bg-[var(--color-accent-500)]/15 text-[var(--color-accent-400)]' : 'text-gray-500 hover:text-gray-300']"
>Folder</button>
</div>
<p class="mt-1.5 text-xs text-gray-600">
{{ format === 'file' ? 'Single markdown file.' : 'Directory with scripts/, references/, and assets/ subdirectories.' }}
</p>
</div>
<!-- Folder files manager (create mode, folder selected) -->
<div v-if="mode === 'create' && !isFork && format === 'folder'" class="rounded-xl border border-white/[0.08] bg-[var(--color-surface-50)] p-4 space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-400">Folder files</p>
<p class="text-[11px] text-gray-600 mt-0.5"><code class="text-gray-500">{{ computedSlug }}/{{ mainFileName }}</code> is generated from the body. Add scripts, docs, or assets here.</p>
</div>
<div class="flex gap-1.5">
<button
type="button"
@click="draftAddMode = draftAddMode === 'create' ? '' : 'create'"
:class="['inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all',
draftAddMode === 'create'
? 'bg-[var(--color-accent-500)]/15 border-[var(--color-accent-500)]/30 text-[var(--color-accent-400)]'
: 'bg-white/[0.06] border-white/[0.06] text-gray-400 hover:text-white hover:bg-white/[0.1]']"
>
<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>
Create
</button>
<button
type="button"
@click="draftAddMode = draftAddMode === 'upload' ? '' : 'upload'"
:class="['inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all',
draftAddMode === 'upload'
? 'bg-[var(--color-accent-500)]/15 border-[var(--color-accent-500)]/30 text-[var(--color-accent-400)]'
: 'bg-white/[0.06] border-white/[0.06] text-gray-400 hover:text-white hover:bg-white/[0.1]']"
>
<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>
Upload
</button>
</div>
</div>
<!-- Create inline file -->
<div v-if="draftAddMode === 'create'" class="rounded-lg border border-white/[0.08] bg-[var(--color-surface-100)] p-3 space-y-3">
<div class="flex items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Directory</label>
<select v-model="draftDir" class="rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm text-white focus:outline-none transition-all">
<option value="scripts">scripts/ — executable code</option>
<option value="references">references/ — docs &amp; context</option>
<option value="assets">assets/ — templates &amp; files</option>
</select>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-500 mb-1">File name</label>
<input
v-model="draftFileName"
type="text"
placeholder="run.sh"
class="w-full rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm font-mono text-white placeholder-gray-600 focus:border-[var(--color-accent-500)]/50 focus:outline-none transition-all"
@keydown.enter.prevent="addDraftInline"
/>
</div>
<button type="button" @click="addDraftInline" :disabled="!draftFileName.trim()" class="rounded-lg bg-[var(--color-accent-500)] px-4 py-2 text-xs font-semibold text-white hover:bg-[var(--color-accent-600)] disabled:opacity-50 transition-all">Add</button>
</div>
<p class="text-[11px] text-gray-600"><strong class="text-gray-500">scripts/</strong> run via hooks or tool calls &middot; <strong class="text-gray-500">references/</strong> Claude reads as context &middot; <strong class="text-gray-500">assets/</strong> copied into the project</p>
</div>
<!-- Upload file -->
<div v-if="draftAddMode === 'upload'" class="rounded-lg border border-white/[0.08] bg-[var(--color-surface-100)] p-3">
<div class="flex items-end gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1">Directory</label>
<select v-model="draftDir" class="rounded-lg border border-white/[0.06] bg-[var(--color-surface-50)] px-3 py-2 text-sm text-white focus:outline-none transition-all">
<option value="scripts">scripts/ — executable code</option>
<option value="references">references/ — docs &amp; context</option>
<option value="assets">assets/ — templates &amp; files</option>
</select>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-500 mb-1">File</label>
<input
type="file"
@change="addDraftUpload"
class="w-full text-sm text-gray-400 file:mr-2 file:rounded-lg file:border-0 file:bg-white/[0.06] file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-gray-300 hover:file:bg-white/[0.1] file:cursor-pointer file:transition-all"
/>
</div>
</div>
<p class="text-[11px] text-gray-600 mt-2">Text files (.sh, .md, .py...) become editable inline. Binary files (images, fonts) are stored as-is.</p>
</div>
<!-- Files list -->
<div v-if="draftFiles.length > 0" class="space-y-2">
<div v-for="(f, idx) in draftFiles" :key="f.path" class="rounded-lg border border-white/[0.06] overflow-hidden">
<!-- File header -->
<div class="flex items-center gap-2 px-3 py-2 bg-white/[0.02] hover:bg-white/[0.04] transition-colors group">
<button v-if="f.kind === 'inline'" type="button" @click="f.expanded = !f.expanded" class="shrink-0 text-gray-600 hover:text-gray-400 transition-colors">
<svg :class="['h-3.5 w-3.5 transition-transform', f.expanded ? 'rotate-90' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</button>
<svg v-else class="h-3.5 w-3.5 shrink-0 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
<span class="flex-1 text-sm text-gray-400 truncate font-mono">{{ f.path }}</span>
<span v-if="f.kind === 'inline'" class="shrink-0 text-[10px] text-gray-600 tabular-nums">{{ f.content.split('\n').length }} lines</span>
<span v-else class="shrink-0 text-xs text-gray-600">{{ formatSize(f.size) }}</span>
<span class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium" :class="f.kind === 'inline' ? 'bg-[var(--color-accent-500)]/10 text-[var(--color-accent-500)]' : 'bg-white/[0.06] text-gray-500'">{{ f.kind === 'inline' ? 'text' : 'binary' }}</span>
<button
type="button"
@click="removeDraftFile(idx)"
class="shrink-0 opacity-0 group-hover:opacity-100 rounded p-1 text-gray-600 hover:text-red-400 hover:bg-red-400/10 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>
</button>
</div>
<!-- Inline editor -->
<div v-if="f.kind === 'inline' && f.expanded" class="border-t border-white/[0.06]">
<textarea
v-model="f.content"
rows="8"
:placeholder="filePlaceholder(f.path)"
class="w-full bg-[var(--color-surface-100)] px-4 py-3 font-mono text-xs text-white placeholder-gray-600 focus:outline-none resize-y leading-relaxed"
/>
</div>
</div>
</div>
<p v-else class="text-xs text-gray-600">No extra files yet. Create scripts, docs, or upload assets.</p>
</div>
<!-- Format badge (edit mode) -->
<div v-if="mode === 'edit' && initialFormat === 'folder'" class="flex items-center gap-2">
<span class="rounded-full px-2.5 py-0.5 text-[11px] font-medium bg-white/[0.06] text-gray-400 border border-white/[0.06]">folder</span>
<span class="text-xs text-gray-600">Folder resource. Manage sub-files below.</span>
</div>
<!-- Type-specific fields -->
<template v-for="field in typeFields" :key="field.key">
<!-- Group toggles on same row -->
<template v-if="field.type === 'toggle'">
<!-- Toggles are grouped below -->
</template>
<FieldRenderer
v-else
:field="field"
:modelValue="fieldValues[field.key]"
@update:modelValue="fieldValues[field.key] = $event"
:tools="availableTools"
:models="availableModels"
:skills="availableSkills"
/>
</template>
<!-- Toggle fields grouped in a row -->
<div v-if="toggleFields.length > 0" class="flex flex-wrap gap-6">
<FieldRenderer
v-for="field in toggleFields"
:key="field.key"
:field="field"
:modelValue="fieldValues[field.key]"
@update:modelValue="fieldValues[field.key] = $event"
/>
</div>
<!-- Body + Preview -->
<div class="grid gap-4 lg:grid-cols-2">
<div>
<label class="flex justify-between text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">
<span>Body</span>
<span :class="bodyLines > 400 ? 'text-amber-500' : ''">{{ bodyLines }}/500 lines</span>
</label>
<textarea
v-model="body"
rows="20"
:placeholder="bodyPlaceholder"
class="w-full rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] px-4 py-3 font-mono 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 resize-y leading-relaxed"
/>
</div>
<div>
<p class="block text-xs font-medium uppercase tracking-wider text-gray-500 mb-1.5">Preview</p>
<div
class="skill-prose rounded-xl border border-white/[0.06] bg-[var(--color-surface-100)] p-5 min-h-[20rem] overflow-auto"
v-html="previewHtml"
/>
</div>
</div>
<!-- File Manager (edit mode, folder format) -->
<FileManager
v-if="mode === 'edit' && initialFormat === 'folder' && slug"
:resourceType="resourceType"
:slug="slug"
/>
<div class="flex items-center gap-4 pt-2">
<button
type="submit"
: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...' : (isFork ? 'Create Fork' : (mode === 'create' ? `Create ${typeSingular}` : '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>
</form>
</template>
<script setup lang="ts">
import { ref, computed, watch, reactive } from 'vue';
import { marked } from 'marked';
import FieldRenderer from './FieldRenderer.vue';
import FileManager from './FileManager.vue';
interface FieldDef {
key: string;
label: string;
type: 'text' | 'select' | 'toggle-grid' | 'toggle' | 'number' | 'json' | 'tags';
placeholder?: string;
hint?: string;
options?: Array<{ value: string; label: string }>;
dynamicOptions?: 'tools' | 'models';
defaultValue?: unknown;
}
const props = defineProps<{
resourceType: string;
typeSingular: string;
typeFields: FieldDef[];
mode: 'create' | 'edit';
slug?: string;
forkOf?: string;
initialName?: string;
initialDescription?: string;
initialTags?: string;
initialBody?: string;
initialAuthor?: string;
initialAuthorEmail?: string;
initialFormat?: 'file' | 'folder';
/** JSON-serialized initial field values for type-specific fields */
initialFieldValues?: string;
availableTools?: string[];
availableModels?: Array<{ id: string; display_name: string }>;
availableSkills?: string[];
availableTags?: string;
}>();
const isFork = computed(() => Boolean(props.forkOf));
const name = ref(props.initialName || '');
const description = ref(props.initialDescription || '');
const body = ref(props.initialBody || '');
const saving = ref(false);
const error = ref('');
const format = ref<'file' | 'folder'>(props.initialFormat || 'file');
// Draft files for folder creation (held in memory until save)
interface DraftFile {
path: string;
kind: 'inline' | 'upload';
content: string; // text content for inline files
file: File | null; // binary for uploads
size: number;
expanded: boolean; // UI: is the editor expanded
}
const draftFiles = ref<DraftFile[]>([]);
const draftDir = ref('scripts');
const draftFileName = ref('');
const draftAddMode = ref<'' | 'create' | 'upload'>('');
const TEXT_EXTENSIONS = new Set([
'md', 'txt', 'sh', 'bash', 'zsh', 'py', 'js', 'ts', 'json', 'yaml', 'yml',
'toml', 'xml', 'html', 'css', 'sql', 'rb', 'go', 'rs', 'lua', 'conf', 'cfg', 'ini',
]);
function isTextFile(name: string): boolean {
const ext = name.split('.').pop()?.toLowerCase() || '';
return TEXT_EXTENSIONS.has(ext);
}
function addDraftInline() {
const fname = draftFileName.value.trim();
if (!fname) return;
const path = `${draftDir.value}/${fname}`;
// Replace if same path
draftFiles.value = draftFiles.value.filter(f => f.path !== path);
draftFiles.value.push({ path, kind: 'inline', content: '', file: null, size: 0, expanded: true });
draftFileName.value = '';
draftAddMode.value = '';
}
function addDraftUpload(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const path = `${draftDir.value}/${file.name}`;
draftFiles.value = draftFiles.value.filter(f => f.path !== path);
if (isTextFile(file.name)) {
// Read as text so it's editable
const reader = new FileReader();
reader.onload = () => {
draftFiles.value.push({
path, kind: 'inline', content: reader.result as string, file: null,
size: file.size, expanded: false,
});
};
reader.readAsText(file);
} else {
draftFiles.value.push({ path, kind: 'upload', content: '', file, size: file.size, expanded: false });
}
input.value = '';
draftAddMode.value = '';
}
function removeDraftFile(idx: number) {
draftFiles.value.splice(idx, 1);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function draftFileToBlob(df: DraftFile): Blob {
if (df.kind === 'upload' && df.file) return df.file;
return new Blob([df.content], { type: 'text/plain' });
}
function filePlaceholder(filePath: string): string {
const dir = filePath.split('/')[0];
const fname = filePath.split('/').pop() || '';
if (dir === 'scripts') {
return `#!/usr/bin/env bash\n# Executable code that Claude runs via hooks or tool calls.\n# Example: a linter wrapper, a code generator, a deploy helper.\n\necho "Running ${fname}..."`;
}
if (dir === 'references') {
return `# ${fname}\n\nReference documentation that Claude reads for context.\nPut API docs, style guides, architecture notes, or\nany material Claude should consult while using this skill.`;
}
if (dir === 'assets') {
return `# ${fname}\n\nStatic resources: templates, config files, schemas,\nor any files the skill copies into the project.\nExample: a .eslintrc template, a Dockerfile, a JSON schema.`;
}
return `Write ${fname} content...`;
}
// Fork author fields
const forkAuthorName = ref('');
const forkAuthorEmail = ref('');
// Load token from localStorage
const authorToken = ref(
typeof localStorage !== 'undefined'
? localStorage.getItem('grimoired-token') || ''
: ''
);
// Tags
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 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 (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);
}
// Type-specific field values
const parsedInitial = props.initialFieldValues ? JSON.parse(props.initialFieldValues) : {};
const fieldValues = reactive<Record<string, unknown>>({});
for (const field of props.typeFields) {
fieldValues[field.key] = parsedInitial[field.key] ?? field.defaultValue ?? (
field.type === 'toggle' ? false :
field.type === 'toggle-grid' ? [] :
field.type === 'tags' ? [] :
''
);
}
// Separate toggles from other fields for layout grouping
const toggleFields = computed(() => props.typeFields.filter(f => f.type === 'toggle'));
const mainFileName = computed(() => {
const map: Record<string, string> = {
skills: 'SKILL.md',
agents: 'AGENT.md',
'output-styles': 'OUTPUT-STYLE.md',
rules: 'RULE.md',
};
return map[props.resourceType] || 'MAIN.md';
});
const bodyPlaceholder = computed(() => {
const placeholders: Record<string, string> = {
skills: '# My Skill\n\nInstructions for Claude...',
agents: '# My Agent\n\nAgent system prompt...',
'output-styles': '# Output Style\n\nFormatting instructions...',
rules: '# Rule\n\nRule content...',
};
return placeholders[props.resourceType] || '# Content\n\nInstructions...';
});
// Slug
const computedSlug = computed(() => {
if (props.mode === 'edit' && props.slug) return props.slug;
return name.value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64) || 'my-resource';
});
const slugMatchesOriginal = computed(() => {
if (!props.forkOf) return false;
return computedSlug.value === props.forkOf;
});
const bodyLines = computed(() => body.value.split('\n').length);
// Preview
let previewHtml = ref('');
let debounceTimer: ReturnType<typeof setTimeout>;
watch(body, (val) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
previewHtml.value = await marked(val || '');
}, 300);
}, { immediate: true });
// Build frontmatter content
function buildContent(): string {
const lines: string[] = ['---'];
lines.push(`name: ${name.value}`);
if (description.value) lines.push(`description: ${description.value}`);
if (isFork.value) {
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 (tags.value.length > 0) lines.push(`tags: ${tags.value.join(', ')}`);
// Type-specific fields
for (const field of props.typeFields) {
const val = fieldValues[field.key];
if (field.type === 'toggle') {
// Only write non-default values
const def = field.defaultValue ?? false;
if (val !== def) {
lines.push(`${field.key}: ${val}`);
}
} else if (field.type === 'toggle-grid') {
const arr = Array.isArray(val) ? val : [];
if (arr.length > 0) lines.push(`${field.key}: ${arr.join(', ')}`);
} else if (field.type === 'tags') {
const arr = Array.isArray(val) ? val : [];
if (arr.length > 0) lines.push(`${field.key}: ${arr.join(', ')}`);
} else if (field.type === 'json') {
const str = typeof val === 'string' ? val.trim() : '';
if (str) {
try {
const parsed = JSON.parse(str);
lines.push(`${field.key}: ${JSON.stringify(parsed)}`);
} catch { /* skip invalid JSON */ }
}
} else if (field.type === 'number') {
const num = typeof val === 'string' ? val.trim() : String(val || '');
if (num) lines.push(`${field.key}: ${num}`);
} else {
// text, select
const str = typeof val === 'string' ? val : '';
if (str) lines.push(`${field.key}: ${str}`);
}
}
lines.push('---');
return lines.join('\n') + '\n\n' + body.value.trim() + '\n';
}
async function save() {
saving.value = true;
error.value = '';
try {
const content = buildContent();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (!isFork.value && authorToken.value) {
headers['Authorization'] = `Bearer ${authorToken.value}`;
}
const apiBase = `/api/resources/${props.resourceType}`;
if (props.mode === 'create') {
const res = await fetch(apiBase, {
method: 'POST',
headers,
body: JSON.stringify({ slug: computedSlug.value, content, format: format.value }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || `Failed to create ${props.typeSingular.toLowerCase()}`);
}
// Upload draft files if folder format
if (format.value === 'folder' && draftFiles.value.length > 0) {
const filesApi = `/api/resources/${props.resourceType}/${computedSlug.value}/files`;
const uploadHeaders: Record<string, string> = {};
if (authorToken.value) uploadHeaders['Authorization'] = `Bearer ${authorToken.value}`;
for (const df of draftFiles.value) {
const formData = new FormData();
const blob = draftFileToBlob(df);
formData.append('file', blob, df.path.split('/').pop());
formData.append('path', df.path);
await fetch(filesApi, { method: 'POST', headers: uploadHeaders, body: formData });
}
}
window.location.href = `/${props.resourceType}/${computedSlug.value}`;
} else {
const res = await fetch(`${apiBase}/${props.slug}`, {
method: 'PUT',
headers,
body: JSON.stringify({ content }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || `Failed to update ${props.typeSingular.toLowerCase()}`);
}
window.location.href = `/${props.resourceType}/${props.slug}`;
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Something went wrong';
} finally {
saving.value = false;
}
}
</script>

View File

@@ -0,0 +1,402 @@
<template>
<div class="mb-6 space-y-4">
<!-- Type tabs -->
<div class="flex items-center gap-1 rounded-xl border border-white/[0.06] p-1 w-fit">
<button
v-for="tab in typeTabs"
:key="tab.value"
@click="setTypeFilter(tab.value)"
:class="[
'rounded-lg px-3.5 py-1.5 text-sm font-medium transition-all',
typeFilter === tab.value
? 'bg-white/[0.08] text-white'
: 'text-gray-500 hover:text-gray-300 hover:bg-white/[0.03]'
]"
>
{{ tab.label }}
<span class="ml-1 text-xs text-gray-600">{{ tab.count }}</span>
</button>
</div>
<!-- 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 -->
<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 -->
<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>
<!-- 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, computed, watch, nextTick, onMounted } from 'vue';
const props = defineProps<{
authors?: string;
tags?: string;
totalCount?: number;
typeCounts?: string; // JSON: { skills: N, agents: N, ... }
}>();
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 counts = props.typeCounts ? JSON.parse(props.typeCounts) as Record<string, number> : {};
const totalAll = props.totalCount || 0;
const typeTabs = computed(() => [
{ value: '', label: 'All', count: totalAll },
{ value: 'skills', label: 'Skills', count: counts.skills || 0 },
{ value: 'agents', label: 'Agents', count: counts.agents || 0 },
{ value: 'output-styles', label: 'Output Styles', count: counts['output-styles'] || 0 },
{ value: 'rules', label: 'Rules', count: counts.rules || 0 },
]);
const query = ref('');
const typeFilter = ref('');
const viewMode = ref<'grid' | 'table'>('grid');
const currentPage = ref(1);
const filteredCount = ref(totalAll);
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('resources-grid');
const table = document.getElementById('resources-table');
if (grid && table) {
grid.classList.toggle('hidden', mode !== 'grid');
table.classList.toggle('hidden', mode !== 'table');
}
currentPage.value = 1;
nextTick(() => applyFilters());
}
function setTypeFilter(type: string) {
typeFilter.value = type;
currentPage.value = 1;
}
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 || typeFilter.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 tf = typeFilter.value;
const activeId = viewMode.value === 'grid' ? 'resources-grid' : 'resources-table';
const inactiveId = viewMode.value === 'grid' ? 'resources-table' : 'resources-grid';
const activeItems = Array.from(document.querySelectorAll<HTMLElement>(`#${activeId} [data-resource]`));
const inactiveItems = document.querySelectorAll<HTMLElement>(`#${inactiveId} [data-resource]`);
inactiveItems.forEach(el => el.style.display = 'none');
const matching: HTMLElement[] = [];
activeItems.forEach((card) => {
const name = card.dataset.name || '';
const desc = card.dataset.description || '';
const tools = card.dataset.tools || '';
const cardAuthor = card.dataset.author || '';
const cardTags = (card.dataset.tags || '').split(',').filter(Boolean);
const cardType = card.dataset.type || '';
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 matchType = !tf || cardType === tf;
if (matchText && matchAuthor && matchTag && matchType) {
matching.push(card);
}
});
filteredCount.value = matching.length;
const maxPage = Math.max(1, Math.ceil(matching.length / perPage.value));
if (currentPage.value > maxPage) currentPage.value = maxPage;
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 = '';
typeFilter.value = '';
currentPage.value = 1;
}
watch([query, selectedAuthors, selectedTags, typeFilter], () => {
currentPage.value = 1;
applyFilters();
}, { deep: true });
</script>

View File

@@ -327,7 +327,7 @@ const forkAuthorEmail = ref('');
// Load token from localStorage (set by EditGate modal)
const authorToken = ref(
typeof localStorage !== 'undefined'
? localStorage.getItem('skillshere-token') || ''
? localStorage.getItem('grimoired-token') || ''
: ''
);

View File

@@ -5,7 +5,7 @@ interface Props {
title?: string;
}
const { title = 'Skills Here' } = Astro.props;
const { title = 'Grimoired' } = Astro.props;
---
<!doctype html>
@@ -28,23 +28,58 @@ const { title = 'Skills Here' } = Astro.props;
<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>
<img src="/favicon.svg" alt="Grimoired" class="h-8 w-8" />
<span class="text-lg font-bold tracking-tight text-white group-hover:text-accent-400 transition-colors">Grimoired</span>
</a>
<a
href="/new"
<!-- New dropdown -->
<div class="relative" id="new-dropdown">
<button
id="new-btn"
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" />
</svg>
New
<svg class="h-3 w-3 ml-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div id="new-menu" class="hidden absolute right-0 mt-2 w-48 rounded-xl border border-white/[0.08] bg-[var(--color-surface-200)] shadow-2xl overflow-hidden z-50">
<a href="/skills/new" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors">
<span class="h-2 w-2 rounded-full" style="background: #fb923c;"></span>
New Skill
</a>
<a href="/agents/new" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors">
<span class="h-2 w-2 rounded-full" style="background: #818cf8;"></span>
New Agent
</a>
<a href="/output-styles/new" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors">
<span class="h-2 w-2 rounded-full" style="background: #34d399;"></span>
New Output Style
</a>
<a href="/rules/new" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-300 hover:bg-white/[0.06] hover:text-white transition-colors">
<span class="h-2 w-2 rounded-full" style="background: #f472b6;"></span>
New Rule
</a>
</div>
</div>
</div>
</nav>
<main class="relative mx-auto max-w-6xl px-6 py-10">
<slot />
</main>
<script>
// Dropdown toggle
const btn = document.getElementById('new-btn')!;
const menu = document.getElementById('new-menu')!;
btn.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('hidden');
});
document.addEventListener('click', () => menu.classList.add('hidden'));
</script>
</body>
</html>

View File

@@ -27,23 +27,17 @@ function displayName(id: string): string {
.replace(/^claude-/, '');
const parts = clean.split('-');
const words = parts.map((p, i) => {
if (/^\d+$/.test(p) && i === parts.length - 1 && parts.length > 1) {
// Last numeric part after a name: turn "4-6" into "4.6" or "4-5" into "4.5"
const prev = parts[i - 1];
if (/^\d+$/.test(prev)) return null; // will be joined with prev
return p;
}
return p.charAt(0).toUpperCase() + p.slice(1);
}).filter(Boolean);
const words = parts.map(p =>
/^\d+$/.test(p) ? p : p.charAt(0).toUpperCase() + p.slice(1)
);
// Join consecutive numbers with dots: ["4", "6"] → "4.6"
const result: string[] = [];
for (const w of words) {
if (/^\d+$/.test(w!) && result.length > 0 && /^\d+$/.test(result[result.length - 1])) {
if (/^\d+$/.test(w) && result.length > 0 && /^\d+$/.test(result[result.length - 1])) {
result[result.length - 1] += '.' + w;
} else {
result.push(w!);
result.push(w);
}
}

236
src/lib/registry.ts Normal file
View File

@@ -0,0 +1,236 @@
import path from 'node:path';
export const RESOURCE_TYPES = ['skills', 'agents', 'output-styles', 'rules'] as const;
export type ResourceType = (typeof RESOURCE_TYPES)[number];
export function isValidResourceType(type: string): type is ResourceType {
return (RESOURCE_TYPES as readonly string[]).includes(type);
}
export interface FieldDef {
key: string;
label: string;
type: 'text' | 'select' | 'toggle-grid' | 'toggle' | 'number' | 'json' | 'tags';
placeholder?: string;
hint?: string;
options?: Array<{ value: string; label: string }>;
/** For toggle-grid: dynamic options fetched from external source */
dynamicOptions?: 'tools' | 'models' | 'skills';
defaultValue?: unknown;
}
export interface ResourceTypeConfig {
slug: ResourceType;
label: string;
labelSingular: string;
/** Directory inside .claude/ where Claude Code reads these */
claudeDir: string;
/** Directory on server where data is stored */
dataDir: string;
/** Color for UI badges */
color: string;
/** Main file name inside a folder-based resource */
mainFileName: string;
/** Type-specific frontmatter fields */
fields: FieldDef[];
}
/** Allowed subdirectories inside a folder-based resource */
export const FOLDER_SUBDIRS = ['scripts', 'references', 'assets'] as const;
const DATA_ROOT = process.env.DATA_DIR || 'data';
export const REGISTRY: Record<ResourceType, ResourceTypeConfig> = {
skills: {
slug: 'skills',
label: 'Skills',
labelSingular: 'Skill',
claudeDir: 'skills',
dataDir: path.resolve(process.env.SKILLS_DIR || `${DATA_ROOT}/skills`),
color: '#fb923c', // orange
mainFileName: 'SKILL.md',
fields: [
{
key: 'allowed-tools',
label: 'Allowed Tools',
type: 'toggle-grid',
dynamicOptions: 'tools',
hint: 'Select which tools this skill can use',
},
{
key: 'argument-hint',
label: 'Argument Hint',
type: 'text',
placeholder: 'e.g. <file-path>',
},
{
key: 'model',
label: 'Model',
type: 'select',
dynamicOptions: 'models',
},
{
key: 'user-invocable',
label: 'User Invocable',
type: 'toggle',
hint: 'Show in /menu',
defaultValue: true,
},
{
key: 'disable-model-invocation',
label: 'Disable Model Invocation',
type: 'toggle',
hint: 'Manual only',
defaultValue: false,
},
{
key: 'context',
label: 'Context',
type: 'select',
options: [
{ value: '', label: 'Inline (default)' },
{ value: 'fork', label: 'Fork (run in subagent)' },
],
hint: 'Fork runs the skill in an isolated subagent context',
},
{
key: 'agent',
label: 'Agent',
type: 'select',
options: [
{ value: '', label: 'general-purpose (default)' },
{ value: 'Explore', label: 'Explore' },
{ value: 'Plan', label: 'Plan' },
],
},
{
key: 'hooks',
label: 'Hooks',
type: 'json',
placeholder: '{ "preToolExecution": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo pre" }] }] }',
hint: 'JSON object. Leave empty to omit.',
},
],
},
agents: {
slug: 'agents',
label: 'Agents',
labelSingular: 'Agent',
claudeDir: 'agents',
dataDir: path.resolve(process.env.AGENTS_DIR || `${DATA_ROOT}/agents`),
color: '#818cf8', // indigo
mainFileName: 'AGENT.md',
fields: [
{
key: 'tools',
label: 'Tools',
type: 'toggle-grid',
dynamicOptions: 'tools',
hint: 'Tools this agent can use',
},
{
key: 'disallowedTools',
label: 'Disallowed Tools',
type: 'toggle-grid',
dynamicOptions: 'tools',
hint: 'Tools explicitly denied',
},
{
key: 'model',
label: 'Model',
type: 'select',
dynamicOptions: 'models',
},
{
key: 'permissionMode',
label: 'Permission Mode',
type: 'select',
options: [
{ value: '', label: 'Default' },
{ value: 'default', label: 'default' },
{ value: 'plan', label: 'plan' },
{ value: 'bypassPermissions', label: 'bypassPermissions' },
],
},
{
key: 'maxTurns',
label: 'Max Turns',
type: 'number',
placeholder: 'e.g. 10',
},
{
key: 'skills',
label: 'Preloaded Skills',
type: 'tags',
hint: 'Skill slugs to preload',
dynamicOptions: 'skills',
},
{
key: 'mcpServers',
label: 'MCP Servers',
type: 'json',
hint: 'JSON object with MCP server configs',
},
{
key: 'memory',
label: 'Memory',
type: 'select',
options: [
{ value: '', label: 'Default' },
{ value: 'true', label: 'Enabled' },
{ value: 'false', label: 'Disabled' },
],
},
{
key: 'hooks',
label: 'Hooks',
type: 'json',
placeholder: '{ "preToolExecution": [...] }',
hint: 'JSON object. Leave empty to omit.',
},
],
},
'output-styles': {
slug: 'output-styles',
label: 'Output Styles',
labelSingular: 'Output Style',
claudeDir: 'output-styles',
dataDir: path.resolve(process.env.OUTPUT_STYLES_DIR || `${DATA_ROOT}/output-styles`),
color: '#34d399', // emerald
mainFileName: 'OUTPUT-STYLE.md',
fields: [
{
key: 'keep-coding-instructions',
label: 'Keep Coding Instructions',
type: 'toggle',
hint: 'Preserve default coding behavior instructions',
defaultValue: false,
},
],
},
rules: {
slug: 'rules',
label: 'Rules',
labelSingular: 'Rule',
claudeDir: 'rules',
dataDir: path.resolve(process.env.RULES_DIR || `${DATA_ROOT}/rules`),
color: '#f472b6', // pink
mainFileName: 'RULE.md',
fields: [
{
key: 'paths',
label: 'Paths',
type: 'tags',
hint: 'Glob patterns for files this rule applies to (e.g. src/**/*.ts)',
placeholder: 'Add glob pattern...',
},
],
},
};
export function getTypeConfig(type: ResourceType): ResourceTypeConfig {
return REGISTRY[type];
}

358
src/lib/resources.ts Normal file
View File

@@ -0,0 +1,358 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
import { type ResourceType, getTypeConfig, RESOURCE_TYPES, FOLDER_SUBDIRS } from './registry';
const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const MAX_SLUG_LENGTH = 64;
export type ResourceFormat = 'file' | 'folder';
export interface ResourceFileEntry {
relativePath: string;
size: number;
}
export interface ResourceMeta {
slug: string;
resourceType: ResourceType;
name: string;
description: string;
tags: string[];
author: string;
'author-email': string;
'fork-of': string;
format: ResourceFormat;
/** All frontmatter fields (type-specific included) */
fields: Record<string, unknown>;
}
export interface Resource extends ResourceMeta {
content: string;
raw: string;
files: ResourceFileEntry[];
}
export function isValidSlug(slug: string): boolean {
return slug.length >= 2 && slug.length <= MAX_SLUG_LENGTH && SLUG_RE.test(slug);
}
function parseList(val: unknown): string[] {
if (Array.isArray(val)) return val.map(String);
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
return [];
}
function parseResource(
type: ResourceType,
slug: string,
raw: string,
format: ResourceFormat = 'file',
files: ResourceFileEntry[] = [],
): Resource {
const { data, content } = matter(raw);
const fields: Record<string, unknown> = { ...data };
return {
slug,
resourceType: type,
name: (data.name as string) || slug,
description: (data.description as string) || '',
tags: parseList(data.tags),
author: (data.author as string) || '',
'author-email': (data['author-email'] as string) || '',
'fork-of': (data['fork-of'] as string) || '',
format,
fields,
content: content.trim(),
raw,
files,
};
}
// --- Path helpers ---
function filePath(type: ResourceType, slug: string): string {
const config = getTypeConfig(type);
return path.join(config.dataDir, `${slug}.md`);
}
function folderPath(type: ResourceType, slug: string): string {
const config = getTypeConfig(type);
return path.join(config.dataDir, slug);
}
function mainFilePath(type: ResourceType, slug: string): string {
const config = getTypeConfig(type);
return path.join(config.dataDir, slug, config.mainFileName);
}
// --- Format detection ---
export interface ResolvedResource {
format: ResourceFormat;
contentPath: string;
}
export async function resolveResource(type: ResourceType, slug: string): Promise<ResolvedResource | null> {
// Folder has priority
const mfp = mainFilePath(type, slug);
try {
await fs.access(mfp);
return { format: 'folder', contentPath: mfp };
} catch { /* not a folder */ }
const fp = filePath(type, slug);
try {
await fs.access(fp);
return { format: 'file', contentPath: fp };
} catch { /* not found */ }
return null;
}
// --- Sub-file helpers ---
async function collectFiles(dirPath: string): Promise<ResourceFileEntry[]> {
const entries: ResourceFileEntry[] = [];
async function walk(current: string, prefix: string) {
let items: import('node:fs').Dirent[];
try {
items = await fs.readdir(current, { withFileTypes: true });
} catch {
return;
}
for (const item of items) {
const rel = prefix ? `${prefix}/${item.name}` : item.name;
if (item.isDirectory()) {
await walk(path.join(current, item.name), rel);
} else {
const stat = await fs.stat(path.join(current, item.name));
entries.push({ relativePath: rel, size: stat.size });
}
}
}
await walk(dirPath, '');
return entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
}
function validateRelativePath(relativePath: string): void {
if (relativePath.includes('..') || path.isAbsolute(relativePath)) {
throw new Error('Invalid file path: must be relative and cannot contain ..');
}
const parts = relativePath.split('/');
if (parts.length < 2 || !FOLDER_SUBDIRS.includes(parts[0] as typeof FOLDER_SUBDIRS[number])) {
throw new Error(`File path must start with one of: ${FOLDER_SUBDIRS.join(', ')}`);
}
}
// --- CRUD ---
export async function listResources(type: ResourceType): Promise<ResourceMeta[]> {
const config = getTypeConfig(type);
await fs.mkdir(config.dataDir, { recursive: true });
const entries = await fs.readdir(config.dataDir, { withFileTypes: true });
const resources: ResourceMeta[] = [];
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.md')) {
// Simple file resource
const slug = entry.name.replace(/\.md$/, '');
// Skip if a valid folder with same slug exists (folder takes priority)
const folderMainFile = path.join(config.dataDir, slug, config.mainFileName);
try {
await fs.access(folderMainFile);
continue; // Folder resource exists, skip the .md file
} catch { /* no folder, fine */ }
const raw = await fs.readFile(path.join(config.dataDir, entry.name), 'utf-8');
const { content: _, raw: __, files: ___, ...meta } = parseResource(type, slug, raw, 'file');
resources.push(meta);
} else if (entry.isDirectory()) {
// Folder resource — must have mainFileName
const mfp = path.join(config.dataDir, entry.name, config.mainFileName);
try {
const raw = await fs.readFile(mfp, 'utf-8');
const { content: _, raw: __, files: ___, ...meta } = parseResource(type, entry.name, raw, 'folder');
resources.push(meta);
} catch {
// Directory without mainFileName — skip
}
}
}
return resources.sort((a, b) => a.name.localeCompare(b.name));
}
export async function listAllResources(): Promise<ResourceMeta[]> {
const all: ResourceMeta[] = [];
for (const type of RESOURCE_TYPES) {
const resources = await listResources(type);
all.push(...resources);
}
return all.sort((a, b) => a.name.localeCompare(b.name));
}
export async function getAllTags(type?: ResourceType): Promise<string[]> {
if (type) {
const all = await listResources(type);
return [...new Set(all.flatMap(r => r.tags))].sort();
}
const all = await listAllResources();
return [...new Set(all.flatMap(r => r.tags))].sort();
}
export async function getForksOf(type: ResourceType, slug: string): Promise<ResourceMeta[]> {
const all = await listResources(type);
return all.filter(r => r['fork-of'] === slug);
}
export async function getResource(type: ResourceType, slug: string): Promise<Resource | null> {
const resolved = await resolveResource(type, slug);
if (!resolved) return null;
const raw = await fs.readFile(resolved.contentPath, 'utf-8');
let files: ResourceFileEntry[] = [];
if (resolved.format === 'folder') {
const dir = folderPath(type, slug);
const allFiles = await collectFiles(dir);
const config = getTypeConfig(type);
// Exclude the main file from the files list
files = allFiles.filter(f => f.relativePath !== config.mainFileName);
}
return parseResource(type, slug, raw, resolved.format, files);
}
export async function createResource(
type: ResourceType,
slug: string,
content: string,
format: ResourceFormat = 'file',
): Promise<Resource> {
if (!isValidSlug(slug)) {
throw new Error(`Invalid slug: ${slug}`);
}
const config = getTypeConfig(type);
await fs.mkdir(config.dataDir, { recursive: true });
// Check both formats to prevent duplicates
const existing = await resolveResource(type, slug);
if (existing) {
throw new Error(`${config.labelSingular} already exists: ${slug}`);
}
if (format === 'folder') {
const dir = folderPath(type, slug);
await fs.mkdir(dir, { recursive: true });
const mfp = mainFilePath(type, slug);
await fs.writeFile(mfp, content, 'utf-8');
return parseResource(type, slug, content, 'folder');
}
const dest = filePath(type, slug);
await fs.writeFile(dest, content, 'utf-8');
return parseResource(type, slug, content, 'file');
}
export async function updateResource(type: ResourceType, slug: string, content: string): Promise<Resource> {
const config = getTypeConfig(type);
const resolved = await resolveResource(type, slug);
if (!resolved) {
throw new Error(`${config.labelSingular} not found: ${slug}`);
}
await fs.writeFile(resolved.contentPath, content, 'utf-8');
let files: ResourceFileEntry[] = [];
if (resolved.format === 'folder') {
const dir = folderPath(type, slug);
const allFiles = await collectFiles(dir);
files = allFiles.filter(f => f.relativePath !== config.mainFileName);
}
return parseResource(type, slug, content, resolved.format, files);
}
export async function deleteResource(type: ResourceType, slug: string): Promise<void> {
const config = getTypeConfig(type);
const resolved = await resolveResource(type, slug);
if (!resolved) {
throw new Error(`${config.labelSingular} not found: ${slug}`);
}
if (resolved.format === 'folder') {
await fs.rm(folderPath(type, slug), { recursive: true });
} else {
await fs.unlink(resolved.contentPath);
}
}
// --- Sub-file operations (folder resources only) ---
export async function listResourceFiles(type: ResourceType, slug: string): Promise<ResourceFileEntry[]> {
const config = getTypeConfig(type);
const resolved = await resolveResource(type, slug);
if (!resolved || resolved.format !== 'folder') {
return [];
}
const dir = folderPath(type, slug);
const allFiles = await collectFiles(dir);
return allFiles.filter(f => f.relativePath !== config.mainFileName);
}
export async function getResourceFile(type: ResourceType, slug: string, relativePath: string): Promise<Buffer | null> {
validateRelativePath(relativePath);
const resolved = await resolveResource(type, slug);
if (!resolved || resolved.format !== 'folder') return null;
const target = path.join(folderPath(type, slug), relativePath);
try {
return await fs.readFile(target);
} catch {
return null;
}
}
export async function addResourceFile(type: ResourceType, slug: string, relativePath: string, data: Buffer): Promise<void> {
validateRelativePath(relativePath);
const resolved = await resolveResource(type, slug);
if (!resolved || resolved.format !== 'folder') {
throw new Error('Resource is not a folder');
}
const target = path.join(folderPath(type, slug), relativePath);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, data);
}
export async function deleteResourceFile(type: ResourceType, slug: string, relativePath: string): Promise<void> {
validateRelativePath(relativePath);
const resolved = await resolveResource(type, slug);
if (!resolved || resolved.format !== 'folder') {
throw new Error('Resource is not a folder');
}
const target = path.join(folderPath(type, slug), relativePath);
await fs.unlink(target);
}
export async function convertToFolder(type: ResourceType, slug: string): Promise<Resource> {
const config = getTypeConfig(type);
const fp = filePath(type, slug);
let raw: string;
try {
raw = await fs.readFile(fp, 'utf-8');
} catch {
throw new Error(`${config.labelSingular} not found or already a folder: ${slug}`);
}
const dir = folderPath(type, slug);
await fs.mkdir(dir, { recursive: true });
const mfp = mainFilePath(type, slug);
await fs.writeFile(mfp, raw, 'utf-8');
await fs.unlink(fp);
return parseResource(type, slug, raw, 'folder');
}

View File

@@ -1,13 +1,26 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
/**
* Backward-compatible wrapper around resources.ts for skills.
* All new code should use resources.ts directly.
*/
import {
listResources,
getResource,
createResource,
updateResource,
deleteResource,
getAllTags as resourceGetAllTags,
getForksOf as resourceGetForksOf,
isValidSlug,
type ResourceMeta,
type Resource,
type ResourceFormat,
type ResourceFileEntry,
} from './resources';
import { getTypeConfig } from './registry';
export const SKILLS_DIR = path.resolve(
process.env.SKILLS_DIR || 'data/skills'
);
export { isValidSlug };
const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const MAX_SLUG_LENGTH = 64;
export const SKILLS_DIR = getTypeConfig('skills').dataDir;
export interface SkillMeta {
slug: string;
@@ -25,127 +38,81 @@ export interface SkillMeta {
'fork-of': string;
tags: string[];
hooks: Record<string, unknown> | null;
format: ResourceFormat;
}
export interface Skill extends SkillMeta {
content: string;
raw: string;
files: ResourceFileEntry[];
}
export function isValidSlug(slug: string): boolean {
return slug.length >= 2 && slug.length <= MAX_SLUG_LENGTH && SLUG_RE.test(slug);
}
function skillPath(slug: string): string {
return path.join(SKILLS_DIR, `${slug}.md`);
}
function parseTools(val: unknown): string[] {
function parseList(val: unknown): string[] {
if (Array.isArray(val)) return val.map(String);
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
return [];
}
function parseSkill(slug: string, raw: string): Skill {
const { data, content } = matter(raw);
function toSkillMeta(r: ResourceMeta): SkillMeta {
const f = r.fields;
return {
slug,
name: (data.name as string) || slug,
description: (data.description as string) || '',
'allowed-tools': parseTools(data['allowed-tools'] ?? data.allowedTools),
'argument-hint': (data['argument-hint'] as string) || '',
model: (data.model as string) || '',
'user-invocable': data['user-invocable'] !== false,
'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,
slug: r.slug,
name: r.name,
description: r.description,
'allowed-tools': parseList(f['allowed-tools'] ?? f.allowedTools),
'argument-hint': (f['argument-hint'] as string) || '',
model: (f.model as string) || '',
'user-invocable': f['user-invocable'] !== false,
'disable-model-invocation': Boolean(f['disable-model-invocation']),
context: (f.context as string) || '',
agent: (f.agent as string) || '',
author: r.author,
'author-email': r['author-email'],
'fork-of': r['fork-of'],
tags: r.tags,
hooks: (typeof f.hooks === 'object' && f.hooks !== null) ? f.hooks as Record<string, unknown> : null,
format: r.format,
};
}
function toSkill(r: Resource): Skill {
return {
...toSkillMeta(r),
content: r.content,
raw: r.raw,
files: r.files,
};
}
export async function listSkills(): Promise<SkillMeta[]> {
await fs.mkdir(SKILLS_DIR, { recursive: true });
const files = await fs.readdir(SKILLS_DIR);
const skills: SkillMeta[] = [];
for (const file of files) {
if (!file.endsWith('.md')) continue;
const slug = file.replace(/\.md$/, '');
const raw = await fs.readFile(path.join(SKILLS_DIR, file), 'utf-8');
const { content: _, raw: __, ...meta } = parseSkill(slug, raw);
skills.push(meta);
}
return skills.sort((a, b) => a.name.localeCompare(b.name));
const resources = await listResources('skills');
return resources.map(toSkillMeta);
}
export async function getAllTags(): Promise<string[]> {
const all = await listSkills();
return [...new Set(all.flatMap(s => s.tags))].sort();
return resourceGetAllTags('skills');
}
export async function getForksOf(slug: string): Promise<SkillMeta[]> {
const all = await listSkills();
return all.filter(s => s['fork-of'] === slug);
const resources = await resourceGetForksOf('skills', slug);
return resources.map(toSkillMeta);
}
export async function getSkill(slug: string): Promise<Skill | null> {
try {
const raw = await fs.readFile(skillPath(slug), 'utf-8');
return parseSkill(slug, raw);
} catch {
return null;
}
const r = await getResource('skills', slug);
return r ? toSkill(r) : null;
}
export async function createSkill(slug: string, content: string): Promise<Skill> {
if (!isValidSlug(slug)) {
throw new Error(`Invalid slug: ${slug}`);
}
await fs.mkdir(SKILLS_DIR, { recursive: true });
const dest = skillPath(slug);
try {
await fs.access(dest);
throw new Error(`Skill already exists: ${slug}`);
} catch (err) {
if (err instanceof Error && err.message.startsWith('Skill already exists')) {
throw err;
}
}
await fs.writeFile(dest, content, 'utf-8');
return parseSkill(slug, content);
export async function createSkill(slug: string, content: string, format?: ResourceFormat): Promise<Skill> {
const r = await createResource('skills', slug, content, format);
return toSkill(r);
}
export async function updateSkill(slug: string, content: string): Promise<Skill> {
const dest = skillPath(slug);
try {
await fs.access(dest);
} catch {
throw new Error(`Skill not found: ${slug}`);
}
await fs.writeFile(dest, content, 'utf-8');
return parseSkill(slug, content);
const r = await updateResource('skills', slug, content);
return toSkill(r);
}
export async function deleteSkill(slug: string): Promise<void> {
const dest = skillPath(slug);
try {
await fs.access(dest);
} catch {
throw new Error(`Skill not found: ${slug}`);
}
await fs.unlink(dest);
return deleteResource('skills', slug);
}

View File

@@ -1,15 +1,19 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { ResourceType } from './registry';
const STATS_FILE = path.resolve(process.env.STATS_FILE || 'data/stats.json');
export interface SkillStats {
export interface ResourceStats {
downloads: number;
pushes: number;
lastPushedAt: string | null;
}
type StatsStore = Record<string, SkillStats>;
/** @deprecated Use ResourceStats */
export type SkillStats = ResourceStats;
type StatsStore = Record<string, ResourceStats>;
async function readStore(): Promise<StatsStore> {
try {
@@ -28,32 +32,63 @@ async function writeStore(store: StatsStore): Promise<void> {
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];
/** Build the key used in stats.json. New format: "type:slug". Legacy keys (bare slug) are migrated lazily. */
function statsKey(type: ResourceType, slug: string): string {
return `${type}:${slug}`;
}
export async function recordDownload(slug: string): Promise<void> {
function ensure(store: StatsStore, key: string): ResourceStats {
if (!store[key]) {
store[key] = { downloads: 0, pushes: 0, lastPushedAt: null };
}
return store[key];
}
/** Lazy migration: if a bare slug key exists (legacy) and the namespaced key doesn't, migrate it. */
function migrateKey(store: StatsStore, type: ResourceType, slug: string): string {
const newKey = statsKey(type, slug);
if (!store[newKey] && store[slug] && type === 'skills') {
store[newKey] = store[slug];
delete store[slug];
}
return newKey;
}
export async function recordDownload(slug: string, type: ResourceType = 'skills'): Promise<void> {
const store = await readStore();
ensure(store, slug).downloads++;
const key = migrateKey(store, type, slug);
ensure(store, key).downloads++;
await writeStore(store);
}
export async function recordPush(slug: string): Promise<void> {
export async function recordPush(slug: string, type: ResourceType = 'skills'): Promise<void> {
const store = await readStore();
const entry = ensure(store, slug);
const key = migrateKey(store, type, slug);
const entry = ensure(store, key);
entry.pushes++;
entry.lastPushedAt = new Date().toISOString();
await writeStore(store);
}
export async function getStatsForSlug(slug: string): Promise<SkillStats> {
export async function getStatsForSlug(slug: string, type: ResourceType = 'skills'): Promise<ResourceStats> {
const store = await readStore();
return store[slug] || { downloads: 0, pushes: 0, lastPushedAt: null };
const key = statsKey(type, slug);
// Check namespaced key first, fall back to legacy bare slug for skills
return store[key] || (type === 'skills' ? store[slug] : null) || { downloads: 0, pushes: 0, lastPushedAt: null };
}
export async function getAllStats(): Promise<StatsStore> {
return readStore();
export async function getAllStats(type?: ResourceType): Promise<Record<string, ResourceStats>> {
const store = await readStore();
if (!type) return store;
const prefix = `${type}:`;
const filtered: Record<string, ResourceStats> = {};
for (const [key, val] of Object.entries(store)) {
if (key.startsWith(prefix)) {
filtered[key.slice(prefix.length)] = val;
} else if (type === 'skills' && !key.includes(':')) {
// Legacy bare slug keys are skills
filtered[key] = val;
}
}
return filtered;
}

View File

@@ -1,19 +1,30 @@
import { listResources, getResource } from './resources';
import { listSkills } from './skills';
import { type ResourceType, REGISTRY, RESOURCE_TYPES, getTypeConfig } from './registry';
export function isPowerShell(request: Request): boolean {
const ua = request.headers.get('user-agent') || '';
return /PowerShell/i.test(ua);
}
// --- Push scripts (type-aware) ---
export async function buildPushScript(baseUrl: string, skillsDir: string): Promise<string> {
return buildPushScriptForType(baseUrl, skillsDir, 'skills');
}
export async function buildPushScriptForType(baseUrl: string, resourceDir: string, type: ResourceType): Promise<string> {
const config = getTypeConfig(type);
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
'',
`SKILLS_DIR="${skillsDir}"`,
`RESOURCE_DIR="${resourceDir}"`,
`BASE_URL="${baseUrl}"`,
`RESOURCE_TYPE="${type}"`,
`MAIN_FILE_NAME="${config.mainFileName}"`,
'FILTER="${1:-}"',
'TOKEN_FILE="$HOME/.claude/skills.here-token"',
'TOKEN_FILE="$HOME/.claude/grimoired-token"',
'',
'# Get git author if available',
'AUTHOR_NAME=$(git config user.name 2>/dev/null || echo "")',
@@ -40,15 +51,15 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
' 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)..."',
' echo " Continuing without token (unprotected resources only)..."',
' else',
' echo " Registration failed ($REGISTER_STATUS): $REGISTER_BODY"',
' echo " Continuing without token (unprotected skills only)..."',
' echo " Continuing without token (unprotected resources only)..."',
' fi',
'fi',
'',
'if [ ! -d "$SKILLS_DIR" ]; then',
' echo "No skills directory found at $SKILLS_DIR"',
'if [ ! -d "$RESOURCE_DIR" ]; then',
' echo "No directory found at $RESOURCE_DIR"',
' exit 1',
'fi',
'',
@@ -57,7 +68,7 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
' AUTH_HEADER="Authorization: Bearer $TOKEN"',
'fi',
'',
'push_skill() {',
'push_file_resource() {',
' local file="$1"',
' local slug=$(basename "$file" .md)',
' local content=$(cat "$file")',
@@ -67,12 +78,6 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
' 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',
@@ -80,12 +85,12 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
' -H "Content-Type: application/json" \\',
' -H "Authorization: Bearer $TOKEN" \\',
' -d "{\\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills/$slug")',
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$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")',
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug")',
' fi',
'',
' if [ "$response" = "403" ]; then',
@@ -99,12 +104,12 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
' -H "Content-Type: application/json" \\',
' -H "Authorization: Bearer $TOKEN" \\',
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .)}" \\',
' "$BASE_URL/api/skills")',
' "$BASE_URL/api/resources/$RESOURCE_TYPE")',
' 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")',
' "$BASE_URL/api/resources/$RESOURCE_TYPE")',
' fi',
' if [ "$post_status" = "403" ]; then',
' echo " ✗ $slug (permission denied — token missing or invalid)"',
@@ -115,25 +120,120 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
' echo " ✓ $slug"',
'}',
'',
'push_folder_resource() {',
' local dir="$1"',
' local slug=$(basename "$dir")',
' local main_file="$dir/$MAIN_FILE_NAME"',
'',
' if [ ! -f "$main_file" ]; then',
' echo " ✗ $slug (no $MAIN_FILE_NAME found)"',
' return 1',
' fi',
'',
' local content=$(cat "$main_file")',
'',
' # Inject author',
' 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',
'',
' # Create as folder format, try PUT first then POST',
' 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/resources/$RESOURCE_TYPE/$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/resources/$RESOURCE_TYPE/$slug")',
' fi',
'',
' if [ "$response" = "403" ]; then',
' echo " ✗ $slug (permission denied)"',
' return 1',
' fi',
'',
' if [ "$response" = "404" ]; then',
' if [ -n "$TOKEN" ]; then',
' 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 .), \\"format\\": \\"folder\\"}" \\',
' "$BASE_URL/api/resources/$RESOURCE_TYPE" > /dev/null',
' else',
' curl -sS -o /dev/null -w "%{http_code}" -X POST \\',
' -H "Content-Type: application/json" \\',
' -d "{\\"slug\\": \\"$slug\\", \\"content\\": $(echo "$content" | jq -Rs .), \\"format\\": \\"folder\\"}" \\',
' "$BASE_URL/api/resources/$RESOURCE_TYPE" > /dev/null',
' fi',
' fi',
'',
' # Upload sub-files',
' local file_count=0',
' for subdir in scripts references assets; do',
' if [ -d "$dir/$subdir" ]; then',
' find "$dir/$subdir" -type f | while read -r subfile; do',
' local rel_path="${subfile#$dir/}"',
' local auth_flag=""',
' if [ -n "$TOKEN" ]; then',
' auth_flag="-H \\"Authorization: Bearer $TOKEN\\""',
' fi',
' if [ -n "$TOKEN" ]; then',
' curl -sS -X PUT \\',
' -H "Authorization: Bearer $TOKEN" \\',
' --data-binary "@$subfile" \\',
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug/files/$rel_path" > /dev/null',
' else',
' curl -sS -X PUT \\',
' --data-binary "@$subfile" \\',
' "$BASE_URL/api/resources/$RESOURCE_TYPE/$slug/files/$rel_path" > /dev/null',
' fi',
' done',
' fi',
' done',
'',
' echo " ✓ $slug (folder)"',
'}',
'',
'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"',
' # Check if filter matches a directory (folder resource)',
' dir_path="$RESOURCE_DIR/${FILTER%.md}"',
' dir_path="${dir_path%.md}"',
' file_path="$RESOURCE_DIR/${FILTER%.md}.md"',
' if [ -d "$dir_path" ] && [ -f "$dir_path/$MAIN_FILE_NAME" ]; then',
' if push_folder_resource "$dir_path"; then count=1; else failed=1; fi',
' elif [ -f "$file_path" ]; then',
' if push_file_resource "$file_path"; then count=1; else failed=1; fi',
' else',
' echo "Resource not found: $FILTER"',
' 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',
' # Push all .md files (simple format)',
' for file in "$RESOURCE_DIR"/*.md; do',
' [ -f "$file" ] || continue',
' if push_skill "$file"; then',
' slug_name=$(basename "$file" .md)',
' # Skip if folder version exists',
' if [ -d "$RESOURCE_DIR/$slug_name" ] && [ -f "$RESOURCE_DIR/$slug_name/$MAIN_FILE_NAME" ]; then',
' continue',
' fi',
' if push_file_resource "$file"; then',
' count=$((count + 1))',
' else',
' failed=$((failed + 1))',
' fi',
' done',
' # Push all directories (folder format)',
' for dir in "$RESOURCE_DIR"/*/; do',
' [ -d "$dir" ] || continue',
' [ -f "$dir/$MAIN_FILE_NAME" ] || continue',
' if push_folder_resource "$dir"; then',
' count=$((count + 1))',
' else',
' failed=$((failed + 1))',
@@ -141,9 +241,9 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
' done',
'fi',
'',
'echo "Pushed $count skill(s) to $BASE_URL"',
`echo "Pushed $count resource(s) to $BASE_URL"`,
'if [ "$failed" -gt 0 ]; then',
' echo "$failed skill(s) failed (permission denied)"',
' echo "$failed resource(s) failed (permission denied)"',
'fi',
'',
];
@@ -151,63 +251,120 @@ export async function buildPushScript(baseUrl: string, skillsDir: string): Promi
return lines.join('\n');
}
// --- Sync scripts (type-aware) ---
export async function buildSyncScript(baseUrl: string, skillsDir: string): Promise<string> {
const skills = await listSkills();
return buildSyncScriptForType(baseUrl, skillsDir, 'skills');
}
export async function buildSyncScriptForType(baseUrl: string, targetDir: string, type: ResourceType): Promise<string> {
const resources = await listResources(type);
const config = getTypeConfig(type);
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
'',
`SKILLS_DIR="${skillsDir}"`,
'mkdir -p "$SKILLS_DIR"',
`TARGET_DIR="${targetDir}"`,
'mkdir -p "$TARGET_DIR"',
'',
];
if (skills.length === 0) {
lines.push('echo "No skills available to sync."');
if (resources.length === 0) {
lines.push(`echo "No ${type} available to sync."`);
} else {
lines.push(`echo "Syncing ${skills.length} skill(s) from ${baseUrl}..."`);
lines.push(`echo "Syncing ${resources.length} ${type} 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}"`);
for (const r of resources) {
if (r.format === 'folder') {
// Fetch full resource to get file list
const full = await getResource(type, r.slug);
if (!full) continue;
lines.push(`mkdir -p "$TARGET_DIR/${r.slug}"`);
lines.push(`curl -fsSL "${baseUrl}/${type}/${r.slug}" -o "$TARGET_DIR/${r.slug}/${config.mainFileName}"`);
for (const f of full.files) {
const dir = f.relativePath.split('/').slice(0, -1).join('/');
if (dir) {
lines.push(`mkdir -p "$TARGET_DIR/${r.slug}/${dir}"`);
}
lines.push(`curl -fsSL "${baseUrl}/api/resources/${type}/${r.slug}/files/${f.relativePath}" -o "$TARGET_DIR/${r.slug}/${f.relativePath}"`);
}
const scriptFiles = full.files.filter(f => f.relativePath.startsWith('scripts/'));
for (const f of scriptFiles) {
lines.push(`chmod +x "$TARGET_DIR/${r.slug}/${f.relativePath}"`);
}
lines.push(`echo " ✓ ${r.name} (folder, ${full.files.length + 1} files)"`);
} else {
const resourceUrl = `${baseUrl}/${type}/${r.slug}`;
lines.push(`curl -fsSL "${resourceUrl}" -o "$TARGET_DIR/${r.slug}.md"`);
lines.push(`echo " ✓ ${r.name}"`);
}
}
lines.push('');
lines.push('echo "Done! Skills synced to $SKILLS_DIR"');
lines.push('echo "Done! Synced to $TARGET_DIR"');
}
lines.push('');
return lines.join('\n');
}
// --- PowerShell variants ---
export async function buildSyncScriptPS(baseUrl: string, skillsDir: string): Promise<string> {
const skills = await listSkills();
return buildSyncScriptPSForType(baseUrl, skillsDir, 'skills');
}
export async function buildSyncScriptPSForType(baseUrl: string, targetDir: string, type: ResourceType): Promise<string> {
const resources = await listResources(type);
const config = getTypeConfig(type);
const lines = [
'$ErrorActionPreference = "Stop"',
'',
`$SkillsDir = "${skillsDir}"`,
'New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null',
`$TargetDir = "${targetDir}"`,
'New-Item -ItemType Directory -Force -Path $TargetDir | Out-Null',
'',
];
if (skills.length === 0) {
lines.push('Write-Host "No skills available to sync."');
if (resources.length === 0) {
lines.push(`Write-Host "No ${type} available to sync."`);
} else {
lines.push(`Write-Host "Syncing ${skills.length} skill(s) from ${baseUrl}..."`);
lines.push(`Write-Host "Syncing ${resources.length} ${type} 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}"`);
for (const r of resources) {
if (r.format === 'folder') {
const full = await getResource(type, r.slug);
if (!full) continue;
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $TargetDir "${r.slug}") | Out-Null`);
lines.push(`Invoke-WebRequest -Uri "${baseUrl}/${type}/${r.slug}" -OutFile (Join-Path $TargetDir "${r.slug}\\${config.mainFileName}")`);
for (const f of full.files) {
const dir = f.relativePath.split('/').slice(0, -1).join('\\');
if (dir) {
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $TargetDir "${r.slug}\\${dir}") | Out-Null`);
}
const winPath = f.relativePath.replace(/\//g, '\\');
lines.push(`Invoke-WebRequest -Uri "${baseUrl}/api/resources/${type}/${r.slug}/files/${f.relativePath}" -OutFile (Join-Path $TargetDir "${r.slug}\\${winPath}")`);
}
lines.push(`Write-Host " ✓ ${r.name} (folder, ${full.files.length + 1} files)"`);
} else {
const resourceUrl = `${baseUrl}/${type}/${r.slug}`;
lines.push(`Invoke-WebRequest -Uri "${resourceUrl}" -OutFile (Join-Path $TargetDir "${r.slug}.md")`);
lines.push(`Write-Host " ✓ ${r.name}"`);
}
}
lines.push('');
lines.push('Write-Host "Done! Skills synced to $SkillsDir"');
lines.push('Write-Host "Done! Synced to $TargetDir"');
}
lines.push('');
@@ -215,13 +372,20 @@ export async function buildSyncScriptPS(baseUrl: string, skillsDir: string): Pro
}
export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Promise<string> {
return buildPushScriptPSForType(baseUrl, skillsDir, 'skills');
}
export async function buildPushScriptPSForType(baseUrl: string, resourceDir: string, type: ResourceType): Promise<string> {
const config = getTypeConfig(type);
const lines = [
'$ErrorActionPreference = "Stop"',
'',
`$SkillsDir = "${skillsDir}"`,
`$ResourceDir = "${resourceDir}"`,
`$BaseUrl = "${baseUrl}"`,
`$ResourceType = "${type}"`,
`$MainFileName = "${config.mainFileName}"`,
'$Filter = if ($args.Count -gt 0) { $args[0] } else { "" }',
'$TokenFile = Join-Path $HOME ".claude\\skills.here-token"',
'$TokenFile = Join-Path $HOME ".claude\\grimoired-token"',
'',
'# Get git author if available',
'$AuthorName = try { git config user.name 2>$null } catch { "" }',
@@ -249,19 +413,19 @@ export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Pro
' } else {',
' Write-Host " Registration failed: $_"',
' }',
' Write-Host " Continuing without token (unprotected skills only)..."',
' Write-Host " Continuing without token (unprotected resources only)..."',
' }',
'}',
'',
'if (-not (Test-Path $SkillsDir)) {',
' Write-Host "No skills directory found at $SkillsDir"',
'if (-not (Test-Path $ResourceDir)) {',
' Write-Host "No directory found at $ResourceDir"',
' exit 1',
'}',
'',
'$headers = @{ "Content-Type" = "application/json" }',
'if ($Token) { $headers["Authorization"] = "Bearer $Token" }',
'',
'function Push-Skill($file) {',
'function Push-FileResource($file) {',
' $slug = [IO.Path]::GetFileNameWithoutExtension($file)',
' $content = Get-Content $file -Raw',
'',
@@ -275,7 +439,7 @@ export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Pro
'',
' $body = @{ content = $content } | ConvertTo-Json',
' try {',
' Invoke-WebRequest -Uri "$BaseUrl/api/skills/$slug" -Method PUT -Headers $headers -Body $body | Out-Null',
' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType/$slug" -Method PUT -Headers $headers -Body $body | Out-Null',
' Write-Host " ✓ $slug"',
' return $true',
' } catch {',
@@ -287,7 +451,7 @@ export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Pro
' 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',
' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType" -Method POST -Headers $headers -Body $postBody | Out-Null',
' Write-Host " ✓ $slug"',
' return $true',
' } catch {',
@@ -303,19 +467,92 @@ export async function buildPushScriptPS(baseUrl: string, skillsDir: string): Pro
' return $true',
'}',
'',
'function Push-FolderResource($dir) {',
' $slug = Split-Path $dir -Leaf',
' $mainFile = Join-Path $dir $MainFileName',
' if (-not (Test-Path $mainFile)) {',
' Write-Host " ✗ $slug (no $MainFileName found)"',
' return $false',
' }',
' $content = Get-Content $mainFile -Raw',
'',
' 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/resources/$ResourceType/$slug" -Method PUT -Headers $headers -Body $body | Out-Null',
' } 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; format = "folder" } | ConvertTo-Json',
' try {',
' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType" -Method POST -Headers $headers -Body $postBody | Out-Null',
' } catch {',
' $postCode = $_.Exception.Response.StatusCode.value__',
' if ($postCode -eq 403) {',
' Write-Host " ✗ $slug (permission denied)"',
' return $false',
' }',
' }',
' }',
' }',
'',
' # Upload sub-files',
' foreach ($subdir in @("scripts", "references", "assets")) {',
' $subdirPath = Join-Path $dir $subdir',
' if (Test-Path $subdirPath) {',
' Get-ChildItem -Path $subdirPath -Recurse -File | ForEach-Object {',
' $relPath = $_.FullName.Substring($dir.Length + 1).Replace("\\", "/")',
' $fileHeaders = @{}',
' if ($Token) { $fileHeaders["Authorization"] = "Bearer $Token" }',
' $fileBytes = [IO.File]::ReadAllBytes($_.FullName)',
' Invoke-WebRequest -Uri "$BaseUrl/api/resources/$ResourceType/$slug/files/$relPath" -Method PUT -Headers $fileHeaders -Body $fileBytes | Out-Null',
' }',
' }',
' }',
'',
' Write-Host " ✓ $slug (folder)"',
' 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++ }',
' $dirPath = Join-Path $ResourceDir ($Filter -replace "\\.md$","")',
' $filePath = Join-Path $ResourceDir "$($Filter -replace \'\\.md$\',\'\').md"',
' if ((Test-Path $dirPath -PathType Container) -and (Test-Path (Join-Path $dirPath $MainFileName))) {',
' if (Push-FolderResource $dirPath) { $count++ } else { $failed++ }',
' } elseif (Test-Path $filePath) {',
' if (Push-FileResource $filePath) { $count++ } else { $failed++ }',
' } else {',
' Write-Host "Resource not found: $Filter"; exit 1',
' }',
'} else {',
' Get-ChildItem -Path $SkillsDir -Filter "*.md" | ForEach-Object {',
' if (Push-Skill $_.FullName) { $count++ } else { $failed++ }',
' # Push .md files (skip if folder version exists)',
' Get-ChildItem -Path $ResourceDir -Filter "*.md" | ForEach-Object {',
' $slugName = $_.BaseName',
' $folderPath = Join-Path $ResourceDir $slugName',
' if ((Test-Path $folderPath -PathType Container) -and (Test-Path (Join-Path $folderPath $MainFileName))) { return }',
' if (Push-FileResource $_.FullName) { $count++ } else { $failed++ }',
' }',
' # Push directories',
' Get-ChildItem -Path $ResourceDir -Directory | ForEach-Object {',
' if (Test-Path (Join-Path $_.FullName $MainFileName)) {',
' if (Push-FolderResource $_.FullName) { $count++ } else { $failed++ }',
' }',
' }',
'}',
'',
'Write-Host "Pushed $count skill(s) to $BaseUrl"',
'if ($failed -gt 0) { Write-Host "$failed skill(s) failed (permission denied)" }',
'Write-Host "Pushed $count resource(s) to $BaseUrl"',
'if ($failed -gt 0) { Write-Host "$failed resource(s) failed (permission denied)" }',
'',
];

View File

@@ -1,20 +1,26 @@
---
import Base from '../layouts/Base.astro';
import EditGate from '../components/EditGate.vue';
import DeleteButton from '../components/DeleteButton.vue';
import { getSkill, getForksOf } from '../lib/skills';
import { hasToken } from '../lib/tokens';
import { recordDownload, getStatsForSlug } from '../lib/stats';
import { marked } from 'marked';
/**
* Backward compatibility: /<slug> redirects to /skills/<slug> for browsers,
* serves raw markdown for CLI (curl/wget).
*/
import { getSkill } from '../lib/skills';
import { recordDownload } from '../lib/stats';
const { slug } = Astro.params;
// Skip if slug matches a known route prefix (type pages handle themselves)
const reserved = new Set(['skills', 'agents', 'output-styles', 'rules', 'new', 'api', 'i', 'gi', 'p', 'gp']);
if (reserved.has(slug!)) {
return new Response(null, { status: 404 });
}
const skill = await getSkill(slug!);
if (!skill) {
return Astro.redirect('/');
}
// curl / wget → raw markdown
// CLI (curl/wget) serve raw markdown directly (backward compat)
const accept = Astro.request.headers.get('accept') || '';
if (!accept.includes('text/html')) {
recordDownload(slug!);
@@ -23,165 +29,6 @@ if (!accept.includes('text/html')) {
});
}
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 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`,
};
// Browser → redirect to new URL
return Astro.redirect(`/skills/${slug}`, 301);
---
<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>
<!-- 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="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>
{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 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>
<article class="skill-prose" set:html={html} />
</div>
</Base>
<style>
.os-tab { color: var(--color-gray-600); }
.os-tab.active { background: rgba(255,255,255,0.06); color: white; }
</style>
<script>
// 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 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,53 +1,7 @@
---
import Base from '../../layouts/Base.astro';
import SkillEditor from '../../components/SkillEditor.vue';
import { getSkill, getAllTags } from '../../lib/skills';
import { getAvailableTools } from '../../lib/tools';
import { getAvailableModels } from '../../lib/models';
/**
* Backward compatibility: /<slug>/edit → /skills/<slug>/edit
*/
const { slug } = Astro.params;
const skill = await getSkill(slug!);
if (!skill) {
return Astro.redirect('/');
}
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();
return Astro.redirect(`/skills/${slug}/edit`, 301);
---
<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" />
</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>
<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
/>
</Base>

View File

@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
import { getSkill } from '../../lib/skills';
import { isPowerShell } from '../../lib/sync';
/** Backward compat: /<slug>/gi — installs skill globally to ~/.claude/skills/ */
export const GET: APIRoute = async ({ params, url, request }) => {
const { slug } = params;
const skill = await getSkill(slug!);
@@ -17,7 +18,7 @@ export const GET: APIRoute = async ({ params, url, request }) => {
'$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")`,
`Invoke-WebRequest -Uri "${origin}/skills/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${skill.name} globally to $Dir\\${slug}.md"`,
'',
].join('\n')
@@ -25,7 +26,7 @@ export const GET: APIRoute = async ({ params, url, request }) => {
'#!/usr/bin/env bash',
'set -euo pipefail',
'mkdir -p ~/.claude/skills',
`curl -fsSL "${origin}/${slug}" -o ~/.claude/skills/${slug}.md`,
`curl -fsSL "${origin}/skills/${slug}" -o ~/.claude/skills/${slug}.md`,
`echo "✓ Installed ${skill.name} globally to ~/.claude/skills/${slug}.md"`,
'',
].join('\n');

View File

@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
import { getSkill } from '../../lib/skills';
import { isPowerShell } from '../../lib/sync';
/** Backward compat: /<slug>/i — installs skill to .claude/skills/ */
export const GET: APIRoute = async ({ params, url, request }) => {
const { slug } = params;
const skill = await getSkill(slug!);
@@ -17,7 +18,7 @@ export const GET: APIRoute = async ({ params, url, request }) => {
'$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")`,
`Invoke-WebRequest -Uri "${origin}/skills/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${skill.name} to $Dir\\${slug}.md"`,
'',
].join('\n')
@@ -25,7 +26,7 @@ export const GET: APIRoute = async ({ params, url, request }) => {
'#!/usr/bin/env bash',
'set -euo pipefail',
'mkdir -p .claude/skills',
`curl -fsSL "${origin}/${slug}" -o ".claude/skills/${slug}.md"`,
`curl -fsSL "${origin}/skills/${slug}" -o ".claude/skills/${slug}.md"`,
`echo "✓ Installed ${skill.name} to .claude/skills/${slug}.md"`,
'',
].join('\n');

View File

@@ -0,0 +1,344 @@
---
import Base from '../../layouts/Base.astro';
import EditGate from '../../components/EditGate.vue';
import DeleteButton from '../../components/DeleteButton.vue';
import FolderTree from '../../components/FolderTree.astro';
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../lib/registry';
import { getResource, getForksOf } from '../../lib/resources';
import { hasToken } from '../../lib/tokens';
import { recordDownload, getStatsForSlug } from '../../lib/stats';
import { marked } from 'marked';
const { type, slug } = Astro.params;
if (!type || !isValidResourceType(type)) {
return new Response(null, { status: 404 });
}
const resourceType = type as ResourceType;
const config = getTypeConfig(resourceType);
const resource = await getResource(resourceType, slug!);
if (!resource) {
return Astro.redirect('/');
}
// curl / wget → raw markdown
const accept = Astro.request.headers.get('accept') || '';
if (!accept.includes('text/html')) {
recordDownload(slug!, resourceType);
return new Response(resource.raw, {
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
});
}
const authorHasToken = resource['author-email'] ? await hasToken(resource['author-email']) : false;
const forks = await getForksOf(resourceType, slug!);
const stats = await getStatsForSlug(slug!, resourceType);
const html = await marked(resource.content);
const origin = Astro.url.origin;
const cmds = {
unix: `curl -fsSL ${origin}/${type}/${slug}/i | bash`,
unixGlobal: `curl -fsSL ${origin}/${type}/${slug}/gi | bash`,
win: `irm ${origin}/${type}/${slug}/i | iex`,
winGlobal: `irm ${origin}/${type}/${slug}/gi | iex`,
};
// Extract display fields from frontmatter
const fields = resource.fields;
const allowedTools = Array.isArray(fields['allowed-tools'] ?? fields.allowedTools)
? (fields['allowed-tools'] ?? fields.allowedTools) as string[]
: typeof (fields['allowed-tools'] ?? fields.allowedTools) === 'string'
? (fields['allowed-tools'] ?? fields.allowedTools as string).split(',').map((t: string) => t.trim()).filter(Boolean)
: [];
---
<Base title={`${resource.name} — Grimoired`}>
<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 to {config.label}
</a>
<!-- 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;">
<div class="flex items-center gap-2 mb-1">
<span class="rounded-full px-2.5 py-0.5 text-[11px] font-semibold" style={`background: ${config.color}20; color: ${config.color}; border: 1px solid ${config.color}40;`}>
{config.labelSingular}
</span>
</div>
<h1 class="text-2xl font-bold tracking-tight text-white mb-1">{resource.name}</h1>
{resource.description && <p class="text-gray-500 leading-relaxed mb-3">{resource.description}</p>}
{allowedTools.length > 0 && (
<div class="flex flex-wrap gap-1.5">
{allowedTools.map((tool: string) => (
<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>
)}
{resource.tags.length > 0 && (
<div class="flex flex-wrap gap-1.5 mt-3">
{resource.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>
)}
{resource.author && (
<p class="text-xs text-gray-600 mt-3">by {resource.author}</p>
)}
{resource['fork-of'] && (
<p class="text-xs text-gray-600 mt-1">forked from <a href={`/${type}/${resource['fork-of']}`} class="text-[var(--color-accent-500)] hover:text-[var(--color-accent-400)] transition-colors">{resource['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={`/${type}/${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="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>
{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 {config.labelSingular.toLowerCase()}</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 {config.labelSingular.toLowerCase()}.</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>
<!-- Content + Files -->
{resource.format === 'folder' ? (
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1rem; align-items: start;">
<div class="rounded-2xl border border-white/[0.06] bg-surface-100 p-5 sticky top-4">
<h2 class="text-sm font-semibold text-white mb-3">Files</h2>
<FolderTree
files={resource.files}
slug={slug!}
type={type}
mainFileName={config.mainFileName}
mainFileSize={new TextEncoder().encode(resource.raw).length}
/>
</div>
<div id="content-panel" class="relative rounded-2xl border border-white/[0.06] bg-surface-100 p-8" style="min-width: 0;">
<div class="absolute top-4 right-4 flex items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-[10px] font-medium bg-white/[0.06] text-gray-500 border border-white/[0.06]">folder</span>
<EditGate slug={slug!} authorEmail={resource['author-email']} authorName={resource.author} authorHasToken={authorHasToken} resourceType={type} client:load />
<DeleteButton slug={slug!} authorEmail={resource['author-email']} authorName={resource.author} authorHasToken={authorHasToken} resourceType={type} client:load />
</div>
<article id="content-body" class="skill-prose" set:html={html} />
</div>
</div>
) : (
<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={resource['author-email']} authorName={resource.author} authorHasToken={authorHasToken} resourceType={type} client:load />
<DeleteButton slug={slug!} authorEmail={resource['author-email']} authorName={resource.author} authorHasToken={authorHasToken} resourceType={type} client:load />
</div>
<article class="skill-prose" set:html={html} />
</div>
)}
</Base>
<style>
.os-tab { color: var(--color-gray-600); }
.os-tab.active { background: rgba(255,255,255,0.06); color: white; }
</style>
<script>
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!));
});
document.querySelectorAll<HTMLButtonElement>('[data-copy]').forEach((btn) => {
btn.addEventListener('click', () => {
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);
}
});
});
// File viewer for folder resources
const contentBody = document.getElementById('content-body');
if (contentBody) {
const mainHtml = contentBody.innerHTML;
const treeItems = document.querySelectorAll<HTMLButtonElement>('.tree-item');
function setActive(btn: HTMLButtonElement) {
treeItems.forEach(t => t.classList.remove('active'));
btn.classList.add('active');
}
function isTextResponse(contentType: string): boolean {
if (contentType.startsWith('text/')) return true;
const textTypes = ['application/json', 'application/javascript', 'application/xml', 'application/yaml', 'application/toml', 'application/x-sh'];
return textTypes.some(t => contentType.startsWith(t));
}
function isMdFile(path: string): boolean {
return path.split('.').pop()?.toLowerCase() === 'md';
}
// Main file button
document.querySelector<HTMLButtonElement>('[data-file-main]')?.addEventListener('click', (e) => {
setActive(e.currentTarget as HTMLButtonElement);
contentBody.innerHTML = mainHtml;
contentBody.className = 'skill-prose';
});
// Sub-file buttons
document.querySelectorAll<HTMLButtonElement>('[data-file-path]').forEach(btn => {
btn.addEventListener('click', async () => {
const filePath = btn.dataset.filePath!;
setActive(btn);
const fileName = filePath.split('/').pop() || filePath;
contentBody.className = '';
contentBody.innerHTML = `<p class="text-sm text-gray-500">Loading ${fileName}...</p>`;
try {
const [, rType, rSlug] = window.location.pathname.split('/');
const apiUrl = `/api/resources/${rType}/${rSlug}/files/${filePath}`;
const res = await fetch(apiUrl);
if (!res.ok) throw new Error(`${res.status}`);
const ct = res.headers.get('content-type') || '';
const treatAsText = isTextResponse(ct) || ct === 'application/octet-stream';
if (treatAsText) {
const text = await res.text();
// If octet-stream, verify it looks like text (no null bytes)
if (ct === 'application/octet-stream' && /\0/.test(text)) {
// Actually binary — show download
const blob = new Blob([text]);
const blobUrl = URL.createObjectURL(blob);
contentBody.innerHTML = `
<div class="flex flex-col items-center justify-center py-12 text-center">
<svg class="h-12 w-12 text-gray-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
<p class="text-sm text-gray-400 mb-1">${fileName}</p>
<p class="text-xs text-gray-600 mb-4">${filePath}</p>
<a href="${blobUrl}" download="${fileName}" class="rounded-lg bg-[var(--color-accent-500)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-accent-400)] transition-colors">Download file</a>
</div>
`;
} else {
const header = document.createElement('div');
header.className = 'flex items-center gap-2 mb-4 pb-3 border-b border-white/[0.06]';
header.innerHTML = `
<span class="text-xs font-mono text-gray-500">${filePath}</span>
<a href="${apiUrl}" download class="ml-auto text-xs text-gray-600 hover:text-gray-400 transition-colors">Download</a>
`;
if (isMdFile(filePath)) {
const { marked: clientMarked } = await import('marked');
const rendered = await clientMarked(text);
contentBody.innerHTML = '';
contentBody.appendChild(header);
const article = document.createElement('article');
article.className = 'skill-prose';
article.innerHTML = rendered;
contentBody.appendChild(article);
} else {
contentBody.innerHTML = '';
contentBody.appendChild(header);
const pre = document.createElement('pre');
pre.className = 'rounded-xl bg-surface-50 border border-white/[0.06] p-4 overflow-x-auto';
const code = document.createElement('code');
code.className = 'text-sm font-mono text-gray-300 whitespace-pre';
code.textContent = text;
pre.appendChild(code);
contentBody.appendChild(pre);
}
}
} else {
// Binary file — show download link
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
contentBody.innerHTML = `
<div class="flex flex-col items-center justify-center py-12 text-center">
<svg class="h-12 w-12 text-gray-600 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
<p class="text-sm text-gray-400 mb-1">${fileName}</p>
<p class="text-xs text-gray-600 mb-4">${filePath}</p>
<a href="${blobUrl}" download="${fileName}" class="rounded-lg bg-[var(--color-accent-500)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-accent-400)] transition-colors">Download file</a>
</div>
`;
}
} catch (err) {
contentBody.innerHTML = `<p class="text-sm text-red-400">Failed to load file.</p>`;
}
});
});
}
</script>

View File

@@ -0,0 +1,77 @@
---
import Base from '../../../layouts/Base.astro';
import ResourceEditor from '../../../components/ResourceEditor.vue';
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../../lib/registry';
import { getResource, getAllTags, listResources } from '../../../lib/resources';
import { getAvailableTools } from '../../../lib/tools';
import { getAvailableModels } from '../../../lib/models';
const { type, slug } = Astro.params;
if (!type || !isValidResourceType(type)) {
return new Response(null, { status: 404 });
}
const resourceType = type as ResourceType;
const config = getTypeConfig(resourceType);
const resource = await getResource(resourceType, slug!);
if (!resource) {
return Astro.redirect('/');
}
const availableTools = await getAvailableTools();
const availableModels = await getAvailableModels();
const availableTags = await getAllTags(resourceType);
const skillSlugs = (await listResources('skills')).map(r => r.slug);
// Build initial field values
const initialFieldValues: Record<string, unknown> = {};
for (const field of config.fields) {
const val = resource.fields[field.key];
if (val !== undefined && val !== null) {
if (field.type === 'toggle-grid' || field.type === 'tags') {
if (Array.isArray(val)) {
initialFieldValues[field.key] = val;
} else if (typeof val === 'string') {
initialFieldValues[field.key] = val.split(',').map((t: string) => t.trim()).filter(Boolean);
}
} else if (field.type === 'json') {
initialFieldValues[field.key] = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val);
} else {
initialFieldValues[field.key] = val;
}
}
}
---
<Base title={`Edit ${resource.name} — Grimoired`}>
<a href={`/${type}/${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" />
</svg>
Back to {resource.name}
</a>
<h1 class="text-2xl font-bold tracking-tight text-white mb-2">Edit {config.labelSingular}</h1>
<p class="text-sm text-gray-500 mb-8">Editing <strong class="text-gray-400">{resource.name}</strong>. Users who already installed this will get the updated version on their next sync.</p>
<ResourceEditor
resourceType={type}
typeSingular={config.labelSingular}
typeFields={config.fields}
mode="edit"
slug={slug}
initialName={resource.name}
initialDescription={resource.description}
initialTags={resource.tags.join(', ')}
initialBody={resource.content}
initialAuthor={resource.author}
initialAuthorEmail={resource['author-email']}
initialFormat={resource.format}
initialFieldValues={JSON.stringify(initialFieldValues)}
availableTools={availableTools}
availableModels={availableModels}
availableSkills={skillSlugs}
availableTags={availableTags.join(',')}
client:load
/>
</Base>

View File

@@ -0,0 +1,181 @@
import type { APIRoute } from 'astro';
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../../lib/registry';
import { getResource } from '../../../lib/resources';
import { isPowerShell } from '../../../lib/sync';
function parseList(val: unknown): string[] {
if (Array.isArray(val)) return val.map(String);
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
return [];
}
export const GET: APIRoute = async ({ params, url, request }) => {
const { type, slug } = params;
if (!type || !isValidResourceType(type)) {
return new Response('Not found', { status: 404 });
}
const resourceType = type as ResourceType;
const config = getTypeConfig(resourceType);
const resource = await getResource(resourceType, slug!);
if (!resource) {
return new Response('Not found', { status: 404 });
}
const origin = url.origin;
const ps = isPowerShell(request);
const claudeDir = config.claudeDir;
// Check for preloaded skills (agents can reference skills)
const preloadedSlugs = parseList(resource.fields.skills);
const depSkills: Array<{ slug: string; name: string }> = [];
for (const skillSlug of preloadedSlugs) {
const skill = await getResource('skills', skillSlug);
if (skill) {
depSkills.push({ slug: skill.slug, name: skill.name });
}
}
const skillsClaudeDir = getTypeConfig('skills').claudeDir;
const files = resource.files;
const script = ps
? buildPS(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir)
: buildBash(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir);
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};
function buildBash(
origin: string, type: string, slug: string, name: string, claudeDir: string,
format: string, files: Array<{ relativePath: string }>,
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
): string {
if (format === 'folder') {
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
`DEST=~/.claude/${claudeDir}/${slug}`,
'mkdir -p "$DEST"',
`curl -fsSL "${origin}/${type}/${slug}" -o "$DEST/${getMainFileName(type)}"`,
];
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('/');
if (dir) {
lines.push(`mkdir -p "$DEST/${dir}"`);
}
lines.push(`curl -fsSL "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -o "$DEST/${f.relativePath}"`);
}
const scriptFiles = files.filter(f => f.relativePath.startsWith('scripts/'));
for (const f of scriptFiles) {
lines.push(`chmod +x "$DEST/${f.relativePath}"`);
}
lines.push(`echo "✓ Installed ${name} globally to ~/.claude/${claudeDir}/${slug}/ (${files.length + 1} files)"`);
if (depSkills.length > 0) {
lines.push(`mkdir -p ~/.claude/${skillsClaudeDir}`);
for (const s of depSkills) {
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ~/.claude/${skillsClaudeDir}/${s.slug}.md`);
lines.push(`echo " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
// Simple file format
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
`mkdir -p ~/.claude/${claudeDir}`,
`curl -fsSL "${origin}/${type}/${slug}" -o ~/.claude/${claudeDir}/${slug}.md`,
`echo "✓ Installed ${name} globally to ~/.claude/${claudeDir}/${slug}.md"`,
];
if (depSkills.length > 0) {
lines.push(`mkdir -p ~/.claude/${skillsClaudeDir}`);
for (const s of depSkills) {
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ~/.claude/${skillsClaudeDir}/${s.slug}.md`);
lines.push(`echo " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
function buildPS(
origin: string, type: string, slug: string, name: string, claudeDir: string,
format: string, files: Array<{ relativePath: string }>,
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
): string {
if (format === 'folder') {
const lines = [
'$ErrorActionPreference = "Stop"',
`$Dest = Join-Path $HOME ".claude\\${claudeDir}\\${slug}"`,
'New-Item -ItemType Directory -Force -Path $Dest | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dest "${getMainFileName(type)}")`,
];
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('\\');
if (dir) {
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $Dest "${dir}") | Out-Null`);
}
const winPath = f.relativePath.replace(/\//g, '\\');
lines.push(`Invoke-WebRequest -Uri "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -OutFile (Join-Path $Dest "${winPath}")`);
}
lines.push(`Write-Host "✓ Installed ${name} globally to $Dest\\ (${files.length + 1} files)"`);
if (depSkills.length > 0) {
lines.push(`$SkillsDir = Join-Path $HOME ".claude\\${skillsClaudeDir}"`);
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
for (const s of depSkills) {
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
// Simple file format
const lines = [
'$ErrorActionPreference = "Stop"',
`$Dir = Join-Path $HOME ".claude\\${claudeDir}"`,
'New-Item -ItemType Directory -Force -Path $Dir | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${name} globally to $Dir\\${slug}.md"`,
];
if (depSkills.length > 0) {
lines.push(`$SkillsDir = Join-Path $HOME ".claude\\${skillsClaudeDir}"`);
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
for (const s of depSkills) {
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
function getMainFileName(type: string): string {
const map: Record<string, string> = {
skills: 'SKILL.md',
agents: 'AGENT.md',
'output-styles': 'OUTPUT-STYLE.md',
rules: 'RULE.md',
};
return map[type] || `${type.toUpperCase()}.md`;
}

View File

@@ -0,0 +1,182 @@
import type { APIRoute } from 'astro';
import { isValidResourceType, getTypeConfig, type ResourceType } from '../../../lib/registry';
import { getResource } from '../../../lib/resources';
import { isPowerShell } from '../../../lib/sync';
function parseList(val: unknown): string[] {
if (Array.isArray(val)) return val.map(String);
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
return [];
}
export const GET: APIRoute = async ({ params, url, request }) => {
const { type, slug } = params;
if (!type || !isValidResourceType(type)) {
return new Response('Not found', { status: 404 });
}
const resourceType = type as ResourceType;
const config = getTypeConfig(resourceType);
const resource = await getResource(resourceType, slug!);
if (!resource) {
return new Response('Not found', { status: 404 });
}
const origin = url.origin;
const ps = isPowerShell(request);
const claudeDir = config.claudeDir;
// Check for preloaded skills (agents can reference skills)
const preloadedSlugs = parseList(resource.fields.skills);
const depSkills: Array<{ slug: string; name: string }> = [];
for (const skillSlug of preloadedSlugs) {
const skill = await getResource('skills', skillSlug);
if (skill) {
depSkills.push({ slug: skill.slug, name: skill.name });
}
}
const skillsClaudeDir = getTypeConfig('skills').claudeDir;
const files = resource.files;
const script = ps
? buildPS(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir)
: buildBash(origin, type!, slug!, resource.name, claudeDir, resource.format, files, depSkills, skillsClaudeDir);
return new Response(script, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};
function buildBash(
origin: string, type: string, slug: string, name: string, claudeDir: string,
format: string, files: Array<{ relativePath: string }>,
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
): string {
if (format === 'folder') {
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
`DEST=".claude/${claudeDir}/${slug}"`,
'mkdir -p "$DEST"',
`curl -fsSL "${origin}/${type}/${slug}" -o "$DEST/${getMainFileName(type)}"`,
];
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('/');
if (dir) {
lines.push(`mkdir -p "$DEST/${dir}"`);
}
lines.push(`curl -fsSL "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -o "$DEST/${f.relativePath}"`);
}
// chmod +x for scripts
const scriptFiles = files.filter(f => f.relativePath.startsWith('scripts/'));
for (const f of scriptFiles) {
lines.push(`chmod +x "$DEST/${f.relativePath}"`);
}
lines.push(`echo "✓ Installed ${name} to .claude/${claudeDir}/${slug}/ (${files.length + 1} files)"`);
if (depSkills.length > 0) {
lines.push(`mkdir -p .claude/${skillsClaudeDir}`);
for (const s of depSkills) {
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ".claude/${skillsClaudeDir}/${s.slug}.md"`);
lines.push(`echo " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
// Simple file format
const lines = [
'#!/usr/bin/env bash',
'set -euo pipefail',
`mkdir -p .claude/${claudeDir}`,
`curl -fsSL "${origin}/${type}/${slug}" -o ".claude/${claudeDir}/${slug}.md"`,
`echo "✓ Installed ${name} to .claude/${claudeDir}/${slug}.md"`,
];
if (depSkills.length > 0) {
lines.push(`mkdir -p .claude/${skillsClaudeDir}`);
for (const s of depSkills) {
lines.push(`curl -fsSL "${origin}/skills/${s.slug}" -o ".claude/${skillsClaudeDir}/${s.slug}.md"`);
lines.push(`echo " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
function buildPS(
origin: string, type: string, slug: string, name: string, claudeDir: string,
format: string, files: Array<{ relativePath: string }>,
depSkills: Array<{ slug: string; name: string }>, skillsClaudeDir: string,
): string {
if (format === 'folder') {
const lines = [
'$ErrorActionPreference = "Stop"',
`$Dest = ".claude\\${claudeDir}\\${slug}"`,
'New-Item -ItemType Directory -Force -Path $Dest | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dest "${getMainFileName(type)}")`,
];
for (const f of files) {
const dir = f.relativePath.split('/').slice(0, -1).join('\\');
if (dir) {
lines.push(`New-Item -ItemType Directory -Force -Path (Join-Path $Dest "${dir}") | Out-Null`);
}
const winPath = f.relativePath.replace(/\//g, '\\');
lines.push(`Invoke-WebRequest -Uri "${origin}/api/resources/${type}/${slug}/files/${f.relativePath}" -OutFile (Join-Path $Dest "${winPath}")`);
}
lines.push(`Write-Host "✓ Installed ${name} to .claude\\${claudeDir}\\${slug}\\ (${files.length + 1} files)"`);
if (depSkills.length > 0) {
lines.push(`$SkillsDir = ".claude\\${skillsClaudeDir}"`);
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
for (const s of depSkills) {
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
// Simple file format
const lines = [
'$ErrorActionPreference = "Stop"',
`$Dir = ".claude\\${claudeDir}"`,
'New-Item -ItemType Directory -Force -Path $Dir | Out-Null',
`Invoke-WebRequest -Uri "${origin}/${type}/${slug}" -OutFile (Join-Path $Dir "${slug}.md")`,
`Write-Host "✓ Installed ${name} to $Dir\\${slug}.md"`,
];
if (depSkills.length > 0) {
lines.push(`$SkillsDir = ".claude\\${skillsClaudeDir}"`);
lines.push('New-Item -ItemType Directory -Force -Path $SkillsDir | Out-Null');
for (const s of depSkills) {
lines.push(`Invoke-WebRequest -Uri "${origin}/skills/${s.slug}" -OutFile (Join-Path $SkillsDir "${s.slug}.md")`);
lines.push(`Write-Host " ↳ Installed skill ${s.name}"`);
}
}
lines.push('');
return lines.join('\n');
}
function getMainFileName(type: string): string {
const map: Record<string, string> = {
skills: 'SKILL.md',
agents: 'AGENT.md',
'output-styles': 'OUTPUT-STYLE.md',
rules: 'RULE.md',
};
return map[type] || `${type.toUpperCase()}.md`;
}

View File

@@ -0,0 +1,85 @@
---
import Base from '../../layouts/Base.astro';
import ResourceEditor from '../../components/ResourceEditor.vue';
import { isValidResourceType, getTypeConfig, REGISTRY, type ResourceType } from '../../lib/registry';
import { getResource, getAllTags, listResources } from '../../lib/resources';
import { getAvailableTools } from '../../lib/tools';
import { getAvailableModels } from '../../lib/models';
const { type } = Astro.params;
if (!type || !isValidResourceType(type)) {
return new Response(null, { status: 404 });
}
const resourceType = type as ResourceType;
const config = getTypeConfig(resourceType);
const availableTools = await getAvailableTools();
const availableModels = await getAvailableModels();
const availableTags = await getAllTags(resourceType);
const skillSlugs = (await listResources('skills')).map(r => r.slug);
// Fork support: /skills/new?from=original-slug
const fromSlug = Astro.url.searchParams.get('from');
let forkSource: Awaited<ReturnType<typeof getResource>> = null;
if (fromSlug) {
forkSource = await getResource(resourceType, fromSlug);
}
const isFork = Boolean(forkSource);
const title = isFork ? `Fork ${forkSource!.name} — Grimoired` : `New ${config.labelSingular} — Grimoired`;
// Build initial field values from fork source
const initialFieldValues: Record<string, unknown> = {};
if (forkSource) {
for (const field of config.fields) {
const val = forkSource.fields[field.key];
if (val !== undefined && val !== null) {
if (field.type === 'toggle-grid' || field.type === 'tags') {
// Normalize to arrays
if (Array.isArray(val)) {
initialFieldValues[field.key] = val;
} else if (typeof val === 'string') {
initialFieldValues[field.key] = val.split(',').map((t: string) => t.trim()).filter(Boolean);
}
} else if (field.type === 'json') {
initialFieldValues[field.key] = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val);
} else {
initialFieldValues[field.key] = val;
}
}
}
}
---
<Base title={title}>
<a href={isFork ? `/${type}/${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>
{isFork ? `Back to ${forkSource!.name}` : 'Back'}
</a>
<h1 class="text-2xl font-bold tracking-tight text-white mb-2">{isFork ? `Fork ${config.labelSingular}` : `New ${config.labelSingular}`}</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">Create a new {config.labelSingular.toLowerCase()}. Write instructions in Markdown in the <strong class="text-gray-400">body</strong> section.</p>
)}
<ResourceEditor
resourceType={type}
typeSingular={config.labelSingular}
typeFields={config.fields}
mode="create"
forkOf={isFork ? fromSlug! : undefined}
initialName={forkSource?.name || ''}
initialDescription={forkSource?.description || ''}
initialTags={forkSource?.tags.join(', ') || ''}
initialBody={forkSource?.content || ''}
initialFieldValues={JSON.stringify(initialFieldValues)}
availableTools={availableTools}
availableModels={availableModels}
availableSkills={skillSlugs}
availableTags={availableTags.join(',')}
client:load
/>
</Base>

View File

@@ -0,0 +1,129 @@
import type { APIRoute } from 'astro';
import { isValidResourceType } from '../../../../lib/registry';
import { getResource, updateResource, deleteResource } from '../../../../lib/resources';
import { verifyToken, extractBearerToken, hasToken } from '../../../../lib/tokens';
import { recordPush } from '../../../../lib/stats';
export const GET: APIRoute = async ({ params, request }) => {
const type = params.type!;
if (!isValidResourceType(type)) {
return new Response('Not found', { status: 404 });
}
const resource = await getResource(type, params.slug!);
if (!resource) {
return new Response('Not found', { status: 404 });
}
// If JSON requested, include format and files metadata
const accept = request.headers.get('accept') || '';
if (accept.includes('application/json')) {
return new Response(JSON.stringify(resource), {
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(resource.raw, {
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
});
};
export const PUT: APIRoute = async ({ params, request }) => {
const type = params.type!;
if (!isValidResourceType(type)) {
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
let body: { content?: string };
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
if (!body.content) {
return new Response(JSON.stringify({ error: 'content is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const existing = await getResource(type, params.slug!);
if (!existing) {
return new Response(JSON.stringify({ error: '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 resource.` }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
const resource = await updateResource(type, params.slug!, body.content);
recordPush(params.slug!, type);
return new Response(JSON.stringify(resource), {
headers: { 'Content-Type': 'application/json' },
});
} 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' },
});
}
};
export const DELETE: APIRoute = async ({ params, request }) => {
const type = params.type!;
if (!isValidResourceType(type)) {
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const existing = await getResource(type, params.slug!);
if (!existing) {
return new Response(JSON.stringify({ error: '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 resource.` }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
await deleteResource(type, 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

@@ -0,0 +1,113 @@
import type { APIRoute } from 'astro';
import { isValidResourceType } from '../../../../../../lib/registry';
import { getResource, getResourceFile, addResourceFile, deleteResourceFile } from '../../../../../../lib/resources';
import { verifyToken, extractBearerToken, hasToken } from '../../../../../../lib/tokens';
import { lookup } from 'mrmime';
async function checkAuth(request: Request, resource: { 'author-email': string; author: string }): Promise<Response | null> {
if (resource['author-email'] && await hasToken(resource['author-email'])) {
const token = extractBearerToken(request);
const valid = await verifyToken(resource['author-email'], token);
if (!valid) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
return null;
}
export const GET: APIRoute = async ({ params }) => {
const { type, slug, filePath } = params;
if (!type || !isValidResourceType(type) || !filePath) {
return new Response('Not found', { status: 404 });
}
const data = await getResourceFile(type, slug!, filePath);
if (!data) {
return new Response('Not found', { status: 404 });
}
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const EXTRA_TEXT: Record<string, string> = {
sh: 'text/x-shellscript', bash: 'text/x-shellscript', zsh: 'text/x-shellscript',
py: 'text/x-python', rb: 'text/x-ruby', go: 'text/x-go', rs: 'text/x-rust',
ts: 'text/typescript', tsx: 'text/typescript', jsx: 'text/javascript',
yml: 'text/yaml', toml: 'text/toml', cfg: 'text/plain', conf: 'text/plain',
ini: 'text/plain', env: 'text/plain', tpl: 'text/plain', tmpl: 'text/plain',
hbs: 'text/plain', ejs: 'text/plain', sql: 'text/sql', txt: 'text/plain',
log: 'text/plain', csv: 'text/csv',
};
const mime = lookup(filePath) || EXTRA_TEXT[ext] || 'application/octet-stream';
return new Response(data, {
headers: { 'Content-Type': mime },
});
};
export const PUT: APIRoute = async ({ params, request }) => {
const { type, slug, filePath } = params;
if (!type || !isValidResourceType(type) || !filePath) {
return new Response(JSON.stringify({ error: 'Invalid params' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const resource = await getResource(type, slug!);
if (!resource) {
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
const authErr = await checkAuth(request, resource);
if (authErr) return authErr;
try {
const buffer = Buffer.from(await request.arrayBuffer());
await addResourceFile(type, slug!, filePath, buffer);
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return new Response(JSON.stringify({ error: message }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
};
export const DELETE: APIRoute = async ({ params, request }) => {
const { type, slug, filePath } = params;
if (!type || !isValidResourceType(type) || !filePath) {
return new Response(JSON.stringify({ error: 'Invalid params' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const resource = await getResource(type, slug!);
if (!resource) {
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
const authErr = await checkAuth(request, resource);
if (authErr) return authErr;
try {
await deleteResourceFile(type, slug!, filePath);
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: 400,
headers: { 'Content-Type': 'application/json' },
});
}
};

View File

@@ -0,0 +1,83 @@
import type { APIRoute } from 'astro';
import { isValidResourceType } from '../../../../../../lib/registry';
import { listResourceFiles, addResourceFile, getResource } from '../../../../../../lib/resources';
import { verifyToken, extractBearerToken, hasToken } from '../../../../../../lib/tokens';
export const GET: APIRoute = async ({ params }) => {
const { type, slug } = params;
if (!type || !isValidResourceType(type)) {
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
const files = await listResourceFiles(type, slug!);
return new Response(JSON.stringify({ files }), {
headers: { 'Content-Type': 'application/json' },
});
};
export const POST: APIRoute = async ({ params, request }) => {
const { type, slug } = params;
if (!type || !isValidResourceType(type)) {
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
// Auth check
const resource = await getResource(type, slug!);
if (!resource) {
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
if (resource.format !== 'folder') {
return new Response(JSON.stringify({ error: 'Resource is not a folder' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
if (resource['author-email'] && await hasToken(resource['author-email'])) {
const token = extractBearerToken(request);
const valid = await verifyToken(resource['author-email'], token);
if (!valid) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
const relativePath = formData.get('path') as string | null;
if (!file || !relativePath) {
return new Response(JSON.stringify({ error: 'file and path are required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const buffer = Buffer.from(await file.arrayBuffer());
await addResourceFile(type, slug!, relativePath, buffer);
return new Response(JSON.stringify({ ok: true, path: relativePath }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return new Response(JSON.stringify({ error: message }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
};

View File

@@ -0,0 +1,91 @@
import type { APIRoute } from 'astro';
import matter from 'gray-matter';
import { isValidResourceType } from '../../../../lib/registry';
import { listResources, createResource, isValidSlug } from '../../../../lib/resources';
import { verifyToken, extractBearerToken, hasToken } from '../../../../lib/tokens';
import { recordPush } from '../../../../lib/stats';
export const GET: APIRoute = async ({ params }) => {
const type = params.type!;
if (!isValidResourceType(type)) {
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
const resources = await listResources(type);
return new Response(JSON.stringify(resources), {
headers: { 'Content-Type': 'application/json' },
});
};
export const POST: APIRoute = async ({ params, request }) => {
const type = params.type!;
if (!isValidResourceType(type)) {
return new Response(JSON.stringify({ error: 'Invalid resource type' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
let body: { slug?: string; content?: string; format?: 'file' | 'folder' };
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const { slug, content, format } = body;
if (!slug || !content) {
return new Response(JSON.stringify({ error: 'slug and content are required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
if (!isValidSlug(slug)) {
return new Response(JSON.stringify({ error: 'Invalid slug. Use lowercase alphanumeric and hyphens, 2-64 chars.' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
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. Register first via POST /api/auth/register.' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
}
try {
const resource = await createResource(type, slug, content, format);
recordPush(slug, type);
return new Response(JSON.stringify(resource), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
if (message.includes('already exists')) {
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

@@ -1,8 +1,9 @@
---
import Base from '../layouts/Base.astro';
import SkillCard from '../components/SkillCard.astro';
import SkillSearch from '../components/SkillSearch.vue';
import { listSkills } from '../lib/skills';
import ResourceCard from '../components/ResourceCard.astro';
import ResourceSearch from '../components/ResourceSearch.vue';
import { listResources, listAllResources } from '../lib/resources';
import { RESOURCE_TYPES, REGISTRY } from '../lib/registry';
import { getAllStats } from '../lib/stats';
import { buildSyncScript, buildSyncScriptPS, isPowerShell } from '../lib/sync';
@@ -17,38 +18,62 @@ if (!accept.includes('text/html')) {
});
}
const skills = await listSkills();
// Fetch all resources grouped by type
const resourcesByType: Record<string, Awaited<ReturnType<typeof listResources>>> = {};
const typeCounts: Record<string, number> = {};
for (const type of RESOURCE_TYPES) {
const resources = await listResources(type);
resourcesByType[type] = resources;
typeCounts[type] = resources.length;
}
// Compute fork counts and unique authors
const allResources = Object.entries(resourcesByType).flatMap(([type, resources]) =>
resources.map(r => ({ ...r, type }))
).sort((a, b) => a.name.localeCompare(b.name));
// Compute fork counts
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);
for (const r of allResources) {
if (r['fork-of']) {
const key = `${r.type}:${r['fork-of']}`;
forkCounts.set(key, (forkCounts.get(key) || 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();
const authors = [...new Set(allResources.map(r => r.author).filter(Boolean))].sort();
const allTags = [...new Set(allResources.flatMap(r => r.tags))].sort();
// Get stats for all types
const allStatsMap: Record<string, Record<string, { downloads: number; pushes: number; lastPushedAt: string | null }>> = {};
for (const type of RESOURCE_TYPES) {
allStatsMap[type] = await getAllStats(type);
}
function parseTools(val: unknown): string[] {
if (Array.isArray(val)) return val.map(String);
if (typeof val === 'string') return val.split(',').map(t => t.trim()).filter(Boolean);
return [];
}
---
<Base title="Skills">
{skills.length === 0 ? (
<Base title="Grimoired">
{allResources.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">
<svg class="h-7 w-7 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
</div>
<p class="text-gray-500 text-lg mb-2">No skills yet</p>
<p class="text-gray-600 text-sm mb-6">Create your first skill to get started.</p>
<p class="text-gray-500 text-lg mb-2">No resources yet</p>
<p class="text-gray-600 text-sm mb-6">Create your first skill, agent, output style, or rule to get started.</p>
<a
href="/new"
href="/skills/new"
class="inline-flex items-center gap-1.5 rounded-lg bg-accent-500 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-accent-500/20 hover:bg-accent-600 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" />
</svg>
Create your first skill
Create your first resource
</a>
</div>
) : (
@@ -56,17 +81,19 @@ const allStats = await getAllStats();
<!-- Hero / Quick install -->
<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 class="flex items-center gap-4 mb-3">
<img src="/grimoired.svg" alt="Grimoired logo" class="h-12 w-12" />
<h1 class="text-3xl font-extrabold tracking-tight text-white">Grimoired</h1>
</div>
<p class="text-gray-500 mb-3"><strong class="text-gray-400">Grimoired</strong> (from <em>grimoire</em> &mdash; a book of spells, originally French) is a shared registry for Claude Code resources: skills, agents, output styles, and rules. Resources can be simple prompt files (<code class="text-gray-400 font-mono bg-white/[0.04] px-1 py-0.5 rounded text-xs">.md</code>) or full packages with scripts, references, and assets that Claude picks up automatically.</p>
<p class="text-gray-600 text-sm leading-relaxed">Create, browse, and share reusable prompts that standardize how Claude handles tasks across your team. Install them instantly with a single curl command.</p>
</div>
<!-- Quick install + Quick push -->
<div class="space-y-2">
<!-- Quick install -->
<details class="group rounded-2xl border border-white/[0.06] bg-surface-100">
<details open data-accordion 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>
<h2 class="text-sm font-semibold text-white">Quick install (skills)</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>
@@ -97,10 +124,9 @@ const allStats = await getAllStats();
</div>
</details>
<!-- Quick push -->
<details class="group rounded-2xl border border-white/[0.06] bg-surface-100">
<details data-accordion 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>
<h2 class="text-sm font-semibold text-white">Quick push (skills)</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>
@@ -118,87 +144,106 @@ const allStats = await getAllStats();
<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 + 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) => (
<ResourceSearch
authors={authors.join(',')}
tags={allTags.join(',')}
totalCount={allResources.length}
typeCounts={JSON.stringify(typeCounts)}
client:load
/>
<div id="resources-grid" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{allResources.map((r) => {
const config = REGISTRY[r.type as keyof typeof REGISTRY];
const stats = allStatsMap[r.type]?.[r.slug] || { downloads: 0, pushes: 0, lastPushedAt: null };
const fc = forkCounts.get(`${r.type}:${r.slug}`) || 0;
const tools = parseTools(r.fields['allowed-tools'] ?? r.fields.allowedTools);
return (
<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)}
data-resource
data-name={r.name.toLowerCase()}
data-description={r.description.toLowerCase()}
data-tools={tools.join(' ').toLowerCase()}
data-author={r.author.toLowerCase()}
data-tags={r.tags.join(',').toLowerCase()}
data-type={r.type}
data-forks={String(fc)}
>
<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} />
<ResourceCard
resourceType={r.type}
slug={r.slug}
name={r.name}
description={r.description}
tags={r.tags}
author={r.author}
forkCount={fc}
downloads={stats.downloads}
pushes={stats.pushes}
lastPushedAt={stats.lastPushedAt}
typeLabel={config.labelSingular}
typeColor={config.color}
tools={tools}
/>
</div>
))}
);
})}
</div>
<div id="skills-table" class="hidden overflow-x-auto rounded-2xl border border-white/[0.06]">
<div id="resources-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">Type</th>
<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 };
{allResources.map((r) => {
const config = REGISTRY[r.type as keyof typeof REGISTRY];
const stats = allStatsMap[r.type]?.[r.slug] || { downloads: 0, pushes: 0, lastPushedAt: null };
const fc = forkCounts.get(`${r.type}:${r.slug}`) || 0;
const desc = r.description.length > 80 ? r.description.slice(0, 80) + '...' : r.description;
const tools = parseTools(r.fields['allowed-tools'] ?? r.fields.allowedTools);
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-resource
data-name={r.name.toLowerCase()}
data-description={r.description.toLowerCase()}
data-tools={tools.join(' ').toLowerCase()}
data-author={r.author.toLowerCase()}
data-tags={r.tags.join(',').toLowerCase()}
data-type={r.type}
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>
<span class="rounded-full px-2 py-0.5 text-[10px] font-semibold" style={`background: ${config.color}20; color: ${config.color};`}>
{config.labelSingular}
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<a href={`/${r.type}/${r.slug}`} class="font-medium text-white hover:text-accent-400 transition-colors">{r.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) => (
{r.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>
<td class="px-4 py-3 text-gray-500 whitespace-nowrap">{r.author || '—'}</td>
<td class="px-4 py-3 text-gray-500 whitespace-nowrap text-xs">{stats.lastPushedAt ? new Date(stats.lastPushedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}</td>
</tr>
);
})}
@@ -214,7 +259,6 @@ const allStats = await getAllStats();
.os-tab.active { background: rgba(255,255,255,0.06); color: white; }
</style>
<script>
// OS detection + tab switching
const isWin = /Win/.test(navigator.platform);
function setOS(os: string) {
document.querySelectorAll<HTMLElement>('[data-cmd]').forEach(el => {
@@ -229,7 +273,6 @@ const allStats = await getAllStats();
tab.addEventListener('click', () => setOS(tab.dataset.os!));
});
// Copy buttons
document.querySelectorAll<HTMLButtonElement>('[data-copy]').forEach((btn) => {
btn.addEventListener('click', () => {
const container = btn.parentElement!;
@@ -242,4 +285,15 @@ const allStats = await getAllStats();
}
});
});
// Accordion: only one details[data-accordion] open at a time
document.querySelectorAll<HTMLDetailsElement>('details[data-accordion]').forEach((detail) => {
detail.addEventListener('toggle', () => {
if (detail.open) {
document.querySelectorAll<HTMLDetailsElement>('details[data-accordion]').forEach((other) => {
if (other !== detail) other.removeAttribute('open');
});
}
});
});
</script>

View File

@@ -1,60 +1,8 @@
---
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';
/**
* Backward compatibility: /new → /skills/new
* Preserves query params (e.g. ?from=slug for forking)
*/
const search = Astro.url.search;
return Astro.redirect(`/skills/new${search}`, 301);
---
<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>
{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 ? (
<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

@@ -3,9 +3,9 @@
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--color-accent-400: #fb923c;
--color-accent-500: #f97316;
--color-accent-600: #ea580c;
--color-accent-400: #e04540;
--color-accent-500: #cc3530;
--color-accent-600: #a82a26;
--color-surface-50: #0a0a0f;
--color-surface-100: #12121a;
--color-surface-200: #1a1a25;
@@ -19,7 +19,7 @@ body {
}
::selection {
background-color: rgb(249 115 22 / 0.3);
background-color: rgb(204 53 48 / 0.3);
}
/* Prose overrides for markdown content */
@@ -29,8 +29,8 @@ body {
.skill-prose p { margin-bottom: 0.75rem; color: #a3a3a3; line-height: 1.7; }
.skill-prose ul, .skill-prose ol { margin-bottom: 0.75rem; padding-left: 1.5rem; color: #a3a3a3; }
.skill-prose li { margin-bottom: 0.25rem; line-height: 1.6; }
.skill-prose code { background: rgb(255 255 255 / 0.06); padding: 0.15rem 0.4rem; border-radius: 0.25rem; font-size: 0.85em; color: #fb923c; }
.skill-prose code { background: rgb(255 255 255 / 0.06); padding: 0.15rem 0.4rem; border-radius: 0.25rem; font-size: 0.85em; color: #e04540; }
.skill-prose pre { background: rgb(0 0 0 / 0.4); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-bottom: 1rem; border: 1px solid rgb(255 255 255 / 0.06); }
.skill-prose pre code { background: none; padding: 0; color: #d4d4d4; }
.skill-prose a { color: #fb923c; text-decoration: underline; text-underline-offset: 2px; }
.skill-prose blockquote { border-left: 3px solid #f97316; padding-left: 1rem; color: #737373; font-style: italic; }
.skill-prose a { color: #e04540; text-decoration: underline; text-underline-offset: 2px; }
.skill-prose blockquote { border-left: 3px solid #cc3530; padding-left: 1rem; color: #737373; font-style: italic; }