Skip to content

Commit 7eef900

Browse files
committed
feat(hooks): read-only Hooks page from settings.json
Adds a Hooks entry in the sidebar (lucide Webhook icon) and a page that lists shell hooks configured under the `hooks` key in `~/.claude/settings.json`. Groups by event (PostToolUse, PreToolUse, UserPromptSubmit, SessionStart, …) and shows matcher + derived name + command for each entry. Backend: new `app_core::hooks::list` reads settings.json tolerantly and returns `Vec<HookGroup>` (matcher + entries). Exposed via the `hooks_list` Tauri command.
1 parent d3c290a commit 7eef900

16 files changed

Lines changed: 382 additions & 0 deletions

File tree

frontend/src/components/Sidebar.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Palette,
1111
Package,
1212
History,
13+
Webhook,
1314
Settings,
1415
} from 'lucide-vue-next'
1516
import logoUrl from '@/assets/logo.png'
@@ -19,6 +20,7 @@ import { useSkillsList } from '@/composables/useSkills'
1920
import { usePlansList } from '@/composables/usePlans'
2021
import { useMcpList } from '@/composables/useMcp'
2122
import { useOutputStylesList } from '@/composables/useOutputStyles'
23+
import { useHooksList } from '@/composables/useHooks'
2224
import { usePluginsList } from '@/composables/usePlugins'
2325
import { useProjectsList } from '@/composables/useProjects'
2426
@@ -28,6 +30,7 @@ const skills = useSkillsList()
2830
const plans = usePlansList()
2931
const mcp = useMcpList('global')
3032
const outputStyles = useOutputStylesList()
33+
const hooks = useHooksList()
3134
const plugins = usePluginsList()
3235
const projects = useProjectsList()
3336
@@ -45,6 +48,7 @@ const items = computed<NavItem[]>(() => [
4548
{ to: '/plans', label: 'Plans', icon: Map, count: () => plans.data.value?.length },
4649
{ to: '/mcp', label: 'MCP', icon: Server, count: () => mcp.data.value?.length },
4750
{ to: '/output-styles', label: 'Output styles', icon: Palette, count: () => outputStyles.data.value?.length },
51+
{ to: '/hooks', label: 'Hooks', icon: Webhook, count: () => hooks.data.value?.length },
4852
{ to: '/plugins', label: 'Plugins', icon: Package, count: () => plugins.data.value?.length },
4953
{ to: '/sessions', label: 'Sessions', icon: History, count: () => projects.data.value?.length },
5054
{ to: '/settings', label: 'Settings', icon: Settings, count: () => undefined },
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { useQuery } from '@tanstack/vue-query'
2+
import { qk } from '@/lib/queryKeys'
3+
import { hooksList } from '@/utils/ipc'
4+
5+
export const useHooksList = (workingDir?: string) =>
6+
useQuery({ queryKey: qk.hooks.list(workingDir), queryFn: () => hooksList(workingDir) })

frontend/src/lib/queryKeys.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export const qk = {
3030
all: ['outputStyles'] as const,
3131
list: () => ['outputStyles', 'list'] as const,
3232
},
33+
hooks: {
34+
all: ['hooks'] as const,
35+
list: (wd?: string) => ['hooks', 'list', wd ?? ''] as const,
36+
},
3337
plugins: {
3438
all: ['plugins'] as const,
3539
list: () => ['plugins', 'list'] as const,

frontend/src/pages/hooks/index.vue

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue'
3+
import { Webhook, Terminal, FileCode } from 'lucide-vue-next'
4+
import PageHeader from '@/components/PageHeader.vue'
5+
import QueryStateBoundary from '@/components/QueryStateBoundary.vue'
6+
import EmptyState from '@/components/EmptyState.vue'
7+
import { useHooksList } from '@/composables/useHooks'
8+
import type { HookGroup } from '@/types/ipc'
9+
10+
const { isPending, isError, error, data } = useHooksList()
11+
12+
const eventLabels: Record<string, string> = {
13+
PreToolUse: 'Before Claude uses a tool',
14+
PostToolUse: 'After Claude uses a tool',
15+
UserPromptSubmit: 'UserPromptSubmit',
16+
SessionStart: 'SessionStart',
17+
SessionEnd: 'SessionEnd',
18+
Notification: 'Notification',
19+
Stop: 'Stop',
20+
SubagentStop: 'SubagentStop',
21+
PreCompact: 'PreCompact',
22+
}
23+
24+
interface GroupedEvent {
25+
event: string
26+
label: string
27+
groups: HookGroup[]
28+
}
29+
30+
const grouped = computed<GroupedEvent[]>(() => {
31+
const out = new Map<string, HookGroup[]>()
32+
for (const g of data.value ?? []) {
33+
if (!out.has(g.event)) out.set(g.event, [])
34+
out.get(g.event)!.push(g)
35+
}
36+
return Array.from(out.entries()).map(([event, groups]) => ({
37+
event,
38+
label: eventLabels[event] ?? event,
39+
groups,
40+
}))
41+
})
42+
43+
function deriveName(command: string | null): string {
44+
if (!command) return ''
45+
const dirMatch = command.match(/\/\.claude\/hooks\/([^/]+)\//)
46+
if (dirMatch) return dirMatch[1]
47+
const quoted = command.match(/["']([^"']+)["']/)
48+
if (quoted) {
49+
const base = quoted[1].split('/').pop() ?? ''
50+
return base.replace(/\.(c?js|mjs|ts|sh|py)$/, '')
51+
}
52+
const first = command.split(/\s+/)[0] ?? command
53+
return first.split('/').pop() ?? first
54+
}
55+
56+
function isScript(command: string | null): boolean {
57+
return !!command && /\.(c?js|mjs|ts|sh|py)/.test(command)
58+
}
59+
</script>
60+
61+
<template>
62+
<PageHeader
63+
title="Hooks"
64+
subtitle="Run shell commands automatically when certain events happen in Claude Code."
65+
/>
66+
67+
<QueryStateBoundary :is-pending="isPending" :is-error="isError" :error="error" :data="data">
68+
<template #default="{ data: items }">
69+
<section class="p-6">
70+
<EmptyState v-if="!items?.length" title="No hooks" />
71+
<div v-else class="space-y-8">
72+
<div v-for="bucket in grouped" :key="bucket.event">
73+
<div class="mb-3 flex items-center gap-2">
74+
<Webhook class="h-4 w-4 text-neutral-500 dark:text-neutral-400" />
75+
<span class="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{{ bucket.label }}</span>
76+
<span class="text-xs text-neutral-500 dark:text-neutral-400">{{ bucket.groups.length }}</span>
77+
</div>
78+
<div class="space-y-3">
79+
<div
80+
v-for="(g, gi) in bucket.groups"
81+
:key="`${bucket.event}:${gi}`"
82+
class="rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900"
83+
>
84+
<div
85+
v-for="(entry, ei) in g.entries"
86+
:key="ei"
87+
:class="ei > 0 ? 'mt-4 border-t border-neutral-100 pt-4 dark:border-neutral-800' : ''"
88+
>
89+
<div class="flex flex-wrap items-center gap-2">
90+
<span class="inline-flex items-center gap-1 rounded bg-neutral-100 px-2 py-0.5 text-xs font-mono text-neutral-700 dark:bg-neutral-800 dark:text-neutral-200">
91+
<FileCode v-if="isScript(entry.command)" class="h-3 w-3" />
92+
<Terminal v-else class="h-3 w-3" />
93+
{{ deriveName(entry.command) }}
94+
</span>
95+
<span
96+
v-if="g.matcher"
97+
class="rounded bg-neutral-100 px-2 py-0.5 text-xs font-mono text-neutral-700 dark:bg-neutral-800 dark:text-neutral-200"
98+
>
99+
{{ g.matcher }}
100+
</span>
101+
<span
102+
v-if="entry.timeout != null"
103+
class="text-[11px] text-neutral-500 dark:text-neutral-400"
104+
>
105+
timeout {{ entry.timeout }}s
106+
</span>
107+
</div>
108+
<p
109+
v-if="entry.statusMessage"
110+
class="mt-2 text-xs text-neutral-500 dark:text-neutral-400"
111+
>
112+
{{ entry.statusMessage }}
113+
</p>
114+
<pre
115+
v-if="entry.command"
116+
class="mt-2 overflow-auto whitespace-pre-wrap break-all rounded bg-neutral-50 p-3 font-mono text-xs text-neutral-700 dark:bg-neutral-950 dark:text-neutral-300"
117+
>▸ {{ entry.command }}</pre>
118+
</div>
119+
</div>
120+
</div>
121+
</div>
122+
</div>
123+
</section>
124+
</template>
125+
</QueryStateBoundary>
126+
</template>

frontend/src/typed-router.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ declare module 'vue-router/auto-routes' {
2525
'/commands/': RouteRecordInfo<'/commands/', '/commands', Record<never, never>, Record<never, never>>,
2626
'/commands/[slug]': RouteRecordInfo<'/commands/[slug]', '/commands/:slug', { slug: ParamValue<true> }, { slug: ParamValue<false> }>,
2727
'/commands/new': RouteRecordInfo<'/commands/new', '/commands/new', Record<never, never>, Record<never, never>>,
28+
'/hooks/': RouteRecordInfo<'/hooks/', '/hooks', Record<never, never>, Record<never, never>>,
2829
'/mcp/': RouteRecordInfo<'/mcp/', '/mcp', Record<never, never>, Record<never, never>>,
2930
'/mcp/[name]': RouteRecordInfo<'/mcp/[name]', '/mcp/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
3031
'/mcp/new': RouteRecordInfo<'/mcp/new', '/mcp/new', Record<never, never>, Record<never, never>>,

frontend/src/types/ipc/HookEntry.ts

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/types/ipc/HookGroup.ts

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/types/ipc/index.ts

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/utils/ipc.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
DirEntry,
1212
FileNode,
1313
GitStatus,
14+
HookGroup,
1415
ImproveRequest,
1516
MarketplaceSource,
1617
MarketplaceSourceInput,
@@ -87,6 +88,10 @@ export const plansUpdate = (slug: string, input: PlanInput) =>
8788
invoke<Plan>('plans_update', { slug, input })
8889
export const plansDelete = (slug: string) => invoke<void>('plans_delete', { slug })
8990

91+
// Hooks
92+
export const hooksList = (workingDir?: string) =>
93+
invoke<HookGroup[]>('hooks_list', { workingDir })
94+
9095
// Output styles
9196
export const outputStylesList = (workingDir?: string) =>
9297
invoke<OutputStyle[]>('output_styles_list', { workingDir })

src-tauri/crates/core/src/hooks.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
//! Read-only listing of hooks configured in `~/.claude/settings.json`
2+
//! (and optionally `<wd>/.claude/settings.json`).
3+
//!
4+
//! settings.json shape (events keyed top-level under `hooks`):
5+
//!
6+
//! ```jsonc
7+
//! {
8+
//! "hooks": {
9+
//! "PostToolUse": [
10+
//! { "matcher": "Bash", "hooks": [{ "type": "command", "command": "..." }] }
11+
//! ],
12+
//! "SessionStart": [
13+
//! { "hooks": [...] }
14+
//! ]
15+
//! }
16+
//! }
17+
//! ```
18+
19+
use std::path::Path;
20+
21+
use serde_json::Value;
22+
23+
use crate::types::{HookEntry, HookGroup};
24+
use crate::AppError;
25+
26+
pub fn list(claude_dir: &Path, working_dir: Option<&Path>) -> Result<Vec<HookGroup>, AppError> {
27+
let mut out = Vec::new();
28+
extend_from(&claude_dir.join("settings.json"), &mut out);
29+
if let Some(wd) = working_dir {
30+
extend_from(&wd.join(".claude").join("settings.json"), &mut out);
31+
}
32+
out.sort_by(|a, b| event_order(&a.event).cmp(&event_order(&b.event)).then(a.event.cmp(&b.event)));
33+
Ok(out)
34+
}
35+
36+
fn extend_from(path: &Path, out: &mut Vec<HookGroup>) {
37+
let Ok(raw) = std::fs::read_to_string(path) else {
38+
return;
39+
};
40+
let Ok(v) = serde_json::from_str::<Value>(&raw) else {
41+
return;
42+
};
43+
let Some(hooks_obj) = v.get("hooks").and_then(|h| h.as_object()) else {
44+
return;
45+
};
46+
for (event, value) in hooks_obj {
47+
let Some(arr) = value.as_array() else {
48+
continue;
49+
};
50+
for group in arr {
51+
let matcher = group
52+
.get("matcher")
53+
.and_then(|m| m.as_str())
54+
.map(|s| s.to_string());
55+
let entries = group
56+
.get("hooks")
57+
.and_then(|h| h.as_array())
58+
.map(|arr| {
59+
arr.iter()
60+
.filter_map(|e| serde_json::from_value::<HookEntry>(e.clone()).ok())
61+
.collect::<Vec<_>>()
62+
})
63+
.unwrap_or_default();
64+
if entries.is_empty() {
65+
continue;
66+
}
67+
out.push(HookGroup {
68+
event: event.clone(),
69+
matcher,
70+
entries,
71+
});
72+
}
73+
}
74+
}
75+
76+
/// Stable display order. Unknown events sort to the end alphabetically.
77+
fn event_order(event: &str) -> u8 {
78+
match event {
79+
"PostToolUse" => 0,
80+
"PreToolUse" => 1,
81+
"UserPromptSubmit" => 2,
82+
"SessionStart" => 3,
83+
"SessionEnd" => 4,
84+
"Notification" => 5,
85+
"Stop" => 6,
86+
"SubagentStop" => 7,
87+
"PreCompact" => 8,
88+
_ => 255,
89+
}
90+
}
91+
92+
#[cfg(test)]
93+
mod tests {
94+
use super::*;
95+
96+
#[test]
97+
fn parses_global_hooks() {
98+
let td = tempfile::tempdir().unwrap();
99+
std::fs::write(
100+
td.path().join("settings.json"),
101+
r#"{
102+
"hooks": {
103+
"PostToolUse": [
104+
{"matcher":"Bash","hooks":[{"type":"command","command":"echo hi","timeout":5}]}
105+
],
106+
"SessionStart": [
107+
{"hooks":[{"type":"command","command":"echo start","statusMessage":"loading"}]}
108+
]
109+
}
110+
}"#,
111+
)
112+
.unwrap();
113+
let groups = list(td.path(), None).unwrap();
114+
assert_eq!(groups.len(), 2);
115+
assert_eq!(groups[0].event, "PostToolUse");
116+
assert_eq!(groups[0].matcher.as_deref(), Some("Bash"));
117+
assert_eq!(groups[0].entries[0].command.as_deref(), Some("echo hi"));
118+
assert_eq!(groups[0].entries[0].timeout, Some(5));
119+
assert_eq!(groups[1].event, "SessionStart");
120+
assert!(groups[1].matcher.is_none());
121+
assert_eq!(
122+
groups[1].entries[0].status_message.as_deref(),
123+
Some("loading"),
124+
);
125+
}
126+
127+
#[test]
128+
fn missing_settings_returns_empty() {
129+
let td = tempfile::tempdir().unwrap();
130+
let groups = list(td.path(), None).unwrap();
131+
assert!(groups.is_empty());
132+
}
133+
134+
#[test]
135+
fn empty_entries_dropped() {
136+
let td = tempfile::tempdir().unwrap();
137+
std::fs::write(
138+
td.path().join("settings.json"),
139+
r#"{"hooks":{"PreToolUse":[{"matcher":"X","hooks":[]}]}}"#,
140+
)
141+
.unwrap();
142+
assert!(list(td.path(), None).unwrap().is_empty());
143+
}
144+
}

0 commit comments

Comments
 (0)