Add hooks and CLAUDE.md resource types with install/uninstall scripts
Introduces two new resource types (hooks, claude-md) with full CRUD, visual hook config editor, section-delimited CLAUDE.md installs, uninstall endpoints, and shell injection hardening in sync scripts.
This commit is contained in:
@@ -116,8 +116,8 @@
|
||||
<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">
|
||||
<!-- Format toggle (create mode only, not for forced-folder or forced-file types) -->
|
||||
<div v-if="mode === 'create' && !isFork && !forceFolderType && !forceFileType">
|
||||
<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
|
||||
@@ -136,12 +136,15 @@
|
||||
</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">
|
||||
<!-- Folder files manager (create mode, folder selected, non-hooks types) -->
|
||||
<div v-if="mode === 'create' && !isFork && format === 'folder' && !isHooksType" 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>
|
||||
<p class="text-xs font-medium text-gray-400">{{ isHooksType ? 'Scripts' : 'Folder files' }}</p>
|
||||
<p class="text-[11px] text-gray-600 mt-0.5">
|
||||
<template v-if="isHooksType">Add executable scripts that your hook commands reference.</template>
|
||||
<template v-else><code class="text-gray-500">{{ computedSlug }}/{{ mainFileName }}</code> is generated from the body. Add scripts, docs, or assets here.</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
@@ -176,7 +179,7 @@
|
||||
<!-- 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>
|
||||
<div v-if="!isHooksType">
|
||||
<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>
|
||||
@@ -189,20 +192,21 @@
|
||||
<input
|
||||
v-model="draftFileName"
|
||||
type="text"
|
||||
placeholder="run.sh"
|
||||
:placeholder="isHooksType ? 'lint.sh' : '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 · <strong class="text-gray-500">references/</strong> Claude reads as context · <strong class="text-gray-500">assets/</strong> copied into the project</p>
|
||||
<p v-if="!isHooksType" class="text-[11px] text-gray-600"><strong class="text-gray-500">scripts/</strong> run via hooks or tool calls · <strong class="text-gray-500">references/</strong> Claude reads as context · <strong class="text-gray-500">assets/</strong> copied into the project</p>
|
||||
<p v-else class="text-[11px] text-gray-600">Scripts are saved to <code class="text-gray-500">scripts/</code> and can be referenced in hook commands.</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>
|
||||
<div v-if="!isHooksType">
|
||||
<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>
|
||||
@@ -260,7 +264,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-600">No extra files yet. Create scripts, docs, or upload assets.</p>
|
||||
<p v-else class="text-xs text-gray-600">{{ isHooksType ? 'No scripts yet. Create or upload executable scripts for your hook commands.' : 'No extra files yet. Create scripts, docs, or upload assets.' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Format badge (edit mode) -->
|
||||
@@ -275,6 +279,10 @@
|
||||
<template v-if="field.type === 'toggle'">
|
||||
<!-- Toggles are grouped below -->
|
||||
</template>
|
||||
<!-- hooks-config uses dedicated editor -->
|
||||
<template v-else-if="field.key === 'hooks-config'">
|
||||
<!-- Rendered separately below -->
|
||||
</template>
|
||||
<FieldRenderer
|
||||
v-else
|
||||
:field="field"
|
||||
@@ -286,6 +294,122 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Hook config visual editor (hooks type only) -->
|
||||
<HookConfigEditor
|
||||
v-if="hasHooksConfig"
|
||||
:modelValue="(fieldValues['hooks-config'] as string) || ''"
|
||||
@update:modelValue="fieldValues['hooks-config'] = $event"
|
||||
:tools="availableTools"
|
||||
/>
|
||||
|
||||
<!-- Scripts section for hooks (after hook config) -->
|
||||
<div v-if="mode === 'create' && !isFork && format === 'folder' && isHooksType" 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">Scripts</p>
|
||||
<p class="text-[11px] text-gray-600 mt-0.5">Add executable scripts that your hook commands reference.</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 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="lint.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">Scripts are saved to <code class="text-gray-500">scripts/</code> and can be referenced in hook commands.</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 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 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">
|
||||
<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>
|
||||
<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>
|
||||
<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 scripts yet. Create or upload executable scripts for your hook commands.</p>
|
||||
</div>
|
||||
|
||||
<!-- Toggle fields grouped in a row -->
|
||||
<div v-if="toggleFields.length > 0" class="flex flex-wrap gap-6">
|
||||
<FieldRenderer
|
||||
@@ -297,11 +421,11 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Body + Preview -->
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<!-- Body + Preview (hidden for hooks — Claude only reads hooks.json) -->
|
||||
<div v-if="!isHooksType" 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>{{ isClaudeMdType ? 'Template Content' : 'Body' }}</span>
|
||||
<span :class="bodyLines > 400 ? 'text-amber-500' : ''">{{ bodyLines }}/500 lines</span>
|
||||
</label>
|
||||
<textarea
|
||||
@@ -352,6 +476,7 @@ import { ref, computed, watch, reactive } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import FieldRenderer from './FieldRenderer.vue';
|
||||
import FileManager from './FileManager.vue';
|
||||
import HookConfigEditor from './HookConfigEditor.vue';
|
||||
|
||||
interface FieldDef {
|
||||
key: string;
|
||||
@@ -387,13 +512,24 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const isFork = computed(() => Boolean(props.forkOf));
|
||||
const forceFolderType = computed(() => forceFolderTypes.has(props.resourceType));
|
||||
const hasHooksConfig = computed(() => props.typeFields.some(f => f.key === 'hooks-config'));
|
||||
const isHooksType = computed(() => props.resourceType === 'hooks');
|
||||
const isClaudeMdType = computed(() => props.resourceType === 'claude-md');
|
||||
const forceFileType = computed(() => forceFileTypes.has(props.resourceType));
|
||||
|
||||
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');
|
||||
const forceFolderTypes = new Set(['hooks']);
|
||||
const forceFileTypes = new Set(['claude-md']);
|
||||
const format = ref<'file' | 'folder'>(
|
||||
forceFolderTypes.has(props.resourceType) ? 'folder' :
|
||||
forceFileTypes.has(props.resourceType) ? 'file' :
|
||||
(props.initialFormat || 'file')
|
||||
);
|
||||
|
||||
// Draft files for folder creation (held in memory until save)
|
||||
interface DraftFile {
|
||||
@@ -573,6 +709,8 @@ const mainFileName = computed(() => {
|
||||
agents: 'AGENT.md',
|
||||
'output-styles': 'OUTPUT-STYLE.md',
|
||||
rules: 'RULE.md',
|
||||
hooks: 'HOOK.md',
|
||||
'claude-md': 'CLAUDE-MD.md',
|
||||
};
|
||||
return map[props.resourceType] || 'MAIN.md';
|
||||
});
|
||||
@@ -583,6 +721,8 @@ const bodyPlaceholder = computed(() => {
|
||||
agents: '# My Agent\n\nAgent system prompt...',
|
||||
'output-styles': '# Output Style\n\nFormatting instructions...',
|
||||
rules: '# Rule\n\nRule content...',
|
||||
hooks: '# My Hook\n\nDocumentation for this hook.\nDescribe what it does, when it triggers, and any setup needed.',
|
||||
'claude-md': '## Project Conventions\n\n- Use TypeScript strict mode\n- Prefer functional components\n- Run tests before committing',
|
||||
};
|
||||
return placeholders[props.resourceType] || '# Content\n\nInstructions...';
|
||||
});
|
||||
@@ -633,8 +773,9 @@ function buildContent(): string {
|
||||
|
||||
if (tags.value.length > 0) lines.push(`tags: ${tags.value.join(', ')}`);
|
||||
|
||||
// Type-specific fields
|
||||
// Type-specific fields (skip hooks-config — stored in hooks.json, not frontmatter)
|
||||
for (const field of props.typeFields) {
|
||||
if (field.key === 'hooks-config') continue;
|
||||
const val = fieldValues[field.key];
|
||||
|
||||
if (field.type === 'toggle') {
|
||||
@@ -671,6 +812,20 @@ function buildContent(): string {
|
||||
return lines.join('\n') + '\n\n' + body.value.trim() + '\n';
|
||||
}
|
||||
|
||||
async function saveHooksConfig(slug: string) {
|
||||
const raw = typeof fieldValues['hooks-config'] === 'string' ? fieldValues['hooks-config'] as string : '';
|
||||
const json = raw.trim() || '{}';
|
||||
// Validate JSON
|
||||
try { JSON.parse(json); } catch { return; }
|
||||
const headers: Record<string, string> = {};
|
||||
if (authorToken.value) headers['Authorization'] = `Bearer ${authorToken.value}`;
|
||||
await fetch(`/api/resources/${props.resourceType}/${slug}/files/hooks.json`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: new Blob([json + '\n'], { type: 'application/json' }),
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
@@ -696,6 +851,11 @@ async function save() {
|
||||
throw new Error(data.error || `Failed to create ${props.typeSingular.toLowerCase()}`);
|
||||
}
|
||||
|
||||
// Save hooks.json for hooks type
|
||||
if (props.resourceType === 'hooks') {
|
||||
await saveHooksConfig(computedSlug.value);
|
||||
}
|
||||
|
||||
// Upload draft files if folder format
|
||||
if (format.value === 'folder' && draftFiles.value.length > 0) {
|
||||
const filesApi = `/api/resources/${props.resourceType}/${computedSlug.value}/files`;
|
||||
@@ -721,6 +881,12 @@ async function save() {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || `Failed to update ${props.typeSingular.toLowerCase()}`);
|
||||
}
|
||||
|
||||
// Save hooks.json for hooks type
|
||||
if (props.resourceType === 'hooks') {
|
||||
await saveHooksConfig(props.slug!);
|
||||
}
|
||||
|
||||
window.location.href = `/${props.resourceType}/${props.slug}`;
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user