Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@ hooks --help
- `hooks doctor`
- `hooks run`

### Compact Output

CLI commands default to compact, agent-friendly output. List and search commands
show essential fields, cap terminal rows, and print hints for deeper inspection.
Use detail flags when you need more context:

```bash
hooks list # compact, capped list
hooks list --all # show all rows
hooks list --verbose # include descriptions
hooks search git --limit 5 # cap result rows
hooks info gitguard # full metadata for one hook
hooks docs gitguard # README preview
hooks docs gitguard --verbose
hooks list --json # stable machine-readable full data
```

MCP tools follow the same gradual disclosure pattern: list/search/log/profile
tools return compact summaries by default, while explicit flags such as
`compact:false`, `verbose:true`, or a detail tool like `hooks_info` return full
records.

## Cloud Sync

This package supports cloud sync via `@hasna/cloud`:
Expand Down
30 changes: 25 additions & 5 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,17 @@ describe("CLI", () => {
describe("hooks list", () => {
test("lists all hooks", async () => {
const { stdout } = await run("list");
expect(stdout).toContain("Available hooks (39)");
expect(stdout).toContain("Available hooks (39, showing 20)");
expect(stdout).toContain("Git Safety");
expect(stdout).toContain("Code Quality");
expect(stdout).toContain("Security");
expect(stdout).toContain("Notifications");
expect(stdout).toContain("Context Management");
expect(stdout).toContain("Showing a compact subset");
expect(stdout).not.toContain("Blocks destructive git operations");
});

test("--verbose includes descriptions", async () => {
const { stdout } = await run("list", "--limit", "1", "--verbose");
expect(stdout).toContain("Blocks destructive git operations");
});

test("--json returns all hooks grouped by category", async () => {
Expand All @@ -93,7 +98,7 @@ describe("CLI", () => {

test("lists by category", async () => {
const { stdout } = await run("list", "-c", "Security");
expect(stdout).toContain("Security (2)");
expect(stdout).toContain("Security (2, showing 2)");
expect(stdout).toContain("checksecurity");
expect(stdout).toContain("packageage");
});
Expand Down Expand Up @@ -132,6 +137,13 @@ describe("CLI", () => {
const { stdout } = await run("search", "git");
expect(stdout).toContain("Found");
expect(stdout).toContain("gitguard");
expect(stdout).not.toContain("Blocks destructive git operations");
expect(stdout).not.toContain("--all");
});

test("search --verbose includes descriptions", async () => {
const { stdout } = await run("search", "gitguard", "--verbose");
expect(stdout).toContain("Blocks destructive git operations");
});

test("shows no results for bad query", async () => {
Expand Down Expand Up @@ -320,6 +332,14 @@ describe("CLI", () => {
expect(stdout).toContain("Git Guard");
expect(stdout).toContain("Configuration");
expect(stdout).toContain("Install");
expect(stdout).toContain("README Preview");
expect(stdout).toContain("Claude Code hook that blocks destructive git operations.");
expect(stdout).toContain("--verbose");
});

test("hook-specific docs --verbose includes full README section", async () => {
const { stdout } = await run("docs", "gitguard", "--verbose");
expect(stdout).toContain("README:");
});

test("--json for specific hook includes readme", async () => {
Expand Down Expand Up @@ -690,7 +710,7 @@ describe("CLI", () => {
} finally {
restoreSettings();
}
});
}, 15_000);
});

describe("hooks info --json for every hook", () => {
Expand Down
129 changes: 87 additions & 42 deletions src/cli/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
getHooksByCategory,
searchHooks,
getHook,
type HookMeta,
} from "../lib/registry.js";
import {
installHook,
Expand Down Expand Up @@ -56,6 +57,46 @@ function resolveTarget(options: { target?: string }): "claude" | "gemini" | "all
return "claude";
}

function parseLimit(value: string | undefined, fallback: number, max: number): number {
const parsed = value ? parseInt(value, 10) : fallback;
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.min(parsed, max);
}

function truncateText(value: string | undefined, max = 96): string {
const text = (value ?? "").replace(/\s+/g, " ").trim();
if (text.length <= max) return text;
return `${text.slice(0, Math.max(0, max - 3))}...`;
}

function readmePreview(readme: string, max = 280): string | undefined {
const blocks = readme
.split(/\n\s*\n/)
.map((part) => part.trim())
.filter(Boolean)
.filter((part) => !part.startsWith("#"))
.filter((part) => !part.startsWith("```"))
.filter((part) => !/^\[!\[/.test(part));
const preview = blocks[0]
?? readme.split("\n").map((line) => line.trim()).find((line) => line && !line.startsWith("#"));
return preview ? truncateText(preview, max) : undefined;
}

function hookSummaryLine(hook: HookMeta, options: { verbose?: boolean } = {}): string {
const matcher = hook.matcher ? ` ${hook.matcher}` : "";
const description = options.verbose ? ` - ${truncateText(hook.description, 110)}` : "";
return ` ${chalk.cyan(hook.name.padEnd(17))} ${chalk.dim(`[${hook.event}${matcher}]`)} ${chalk.dim(hook.category)}${description}`;
}

function printDisclosureHint(hidden: number, detailCommand: string, options: { includeAll?: boolean } = {}): void {
const rowControls = options.includeAll ? "--limit, --all, --verbose" : "--limit, --verbose";
if (hidden > 0) {
console.log(chalk.dim(`\n Showing a compact subset. ${hidden} more hidden; use ${rowControls}, or ${detailCommand}.`));
} else {
console.log(chalk.dim(`\n Use --verbose or ${detailCommand} for details.`));
}
}

/** Levenshtein distance for did-you-mean suggestions */
function editDistance(a: string, b: string): number {
const m = a.length, n = b.length;
Expand Down Expand Up @@ -318,10 +359,13 @@ program
.option("-g, --global", "Check global settings", false)
.option("-p, --project", "Check project settings", false)
.option("-t, --target <target>", "Agent target: claude, gemini (default: claude)", "claude")
.option("-n, --limit <n>", "Max rows to show in compact output", "20")
.option("--verbose", "Show descriptions and full detail columns", false)
.option("-j, --json", "Output as JSON", false)
.description("List available or installed hooks")
.action((options) => {
const scope = resolveScope(options);
const limit = options.all ? Number.MAX_SAFE_INTEGER : parseLimit(options.limit, 20, 200);

if (options.registered || options.installed) {
const target = (options.target === "gemini" ? "gemini" : "claude") as "claude" | "gemini";
Expand All @@ -337,13 +381,14 @@ program
console.log(chalk.dim(`No hooks registered (${scope}, ${target})`));
return;
}
console.log(chalk.bold(`\nRegistered hooks — ${scope}/${target} (${registered.length}):\n`));
for (const name of registered) {
const visible = registered.slice(0, limit);
console.log(chalk.bold(`\nRegistered hooks — ${scope}/${target} (${registered.length}, showing ${visible.length}):\n`));
for (const name of visible) {
const meta = getHook(name);
console.log(
` ${chalk.cyan(name)} ${chalk.dim(`[${meta?.event || "unknown"}]`)} - ${meta?.description || ""}`
);
if (meta) console.log(hookSummaryLine(meta, { verbose: options.verbose }));
else console.log(` ${chalk.cyan(name)} ${chalk.dim("[unknown]")}`);
}
printDisclosureHint(registered.length - visible.length, "hooks info <name>", { includeAll: true });
return;
}

Expand All @@ -365,12 +410,10 @@ program
console.log(JSON.stringify(hooks));
return;
}
console.log(chalk.bold(`\n${category} (${hooks.length}):\n`));
for (const h of hooks) {
console.log(
` ${chalk.cyan(h.name)} ${chalk.dim(`[${h.event}]`)} - ${h.description}`
);
}
const visible = hooks.slice(0, limit);
console.log(chalk.bold(`\n${category} (${hooks.length}, showing ${visible.length}):\n`));
for (const h of visible) console.log(hookSummaryLine(h, { verbose: options.verbose }));
printDisclosureHint(hooks.length - visible.length, "hooks info <name>", { includeAll: true });
return;
}

Expand All @@ -384,26 +427,21 @@ program
return;
}

console.log(chalk.bold(`\nAvailable hooks (${HOOKS.length}):\n`));
for (const category of CATEGORIES) {
const hooks = getHooksByCategory(category);
console.log(chalk.bold(`${category} (${hooks.length}):`));
for (const h of hooks) {
console.log(
` ${chalk.cyan(h.name)} ${chalk.dim(`[${h.event}]`)} - ${h.description}`
);
}
console.log();
}
const visible = HOOKS.slice(0, limit);
console.log(chalk.bold(`\nAvailable hooks (${HOOKS.length}, showing ${visible.length}):\n`));
for (const h of visible) console.log(hookSummaryLine(h, { verbose: options.verbose }));
printDisclosureHint(HOOKS.length - visible.length, "hooks info <name>", { includeAll: true });
});

// Search command
program
.command("search")
.argument("<query>", "Search term")
.option("-n, --limit <n>", "Max rows to show in compact output", "10")
.option("--verbose", "Show descriptions for search results", false)
.option("-j, --json", "Output as JSON", false)
.description("Search for hooks")
.action((query: string, options: { json: boolean }) => {
.action((query: string, options: { limit: string; verbose: boolean; json: boolean }) => {
const results = searchHooks(query);
if (options.json) {
console.log(JSON.stringify(results));
Expand All @@ -413,13 +451,11 @@ program
console.log(chalk.dim(`No hooks found for "${query}"`));
return;
}
console.log(chalk.bold(`\nFound ${results.length} hook(s):\n`));
for (const h of results) {
console.log(
` ${chalk.cyan(h.name)} ${chalk.dim(`[${h.event}] [${h.category}]`)}`
);
console.log(` ${h.description}`);
}
const limit = parseLimit(options.limit, 10, 100);
const visible = results.slice(0, limit);
console.log(chalk.bold(`\nFound ${results.length} hook(s), showing ${visible.length}:\n`));
for (const h of visible) console.log(hookSummaryLine(h, { verbose: options.verbose }));
printDisclosureHint(results.length - visible.length, "hooks info <name>");
});

// Remove command
Expand Down Expand Up @@ -683,9 +719,10 @@ program
program
.command("docs")
.argument("[hook]", "Hook name (shows general docs if omitted)")
.option("--verbose", "Print full hook README content", false)
.option("-j, --json", "Output as JSON", false)
.description("Show documentation for hooks")
.action((hook: string | undefined, options: { json: boolean }) => {
.action((hook: string | undefined, options: { verbose: boolean; json: boolean }) => {
if (hook) {
const meta = getHook(hook);
if (!meta) {
Expand Down Expand Up @@ -721,11 +758,18 @@ program
console.log(` hooks install ${meta.name} --project # project only`);
console.log();

if (readme) {
if (readme && options.verbose) {
console.log(chalk.bold(" README:\n"));
for (const line of readme.split("\n")) {
console.log(` ${line}`);
}
} else if (readme) {
const preview = readmePreview(readme);
if (preview) {
console.log(chalk.bold(" README Preview:\n"));
console.log(` ${preview}\n`);
}
console.log(chalk.dim(` README has ${readme.split("\n").length} lines. Use hooks docs ${meta.name} --verbose for the full README, or --json for machine-readable output.`));
}
return;
}
Expand Down Expand Up @@ -795,8 +839,9 @@ program
}

console.log(chalk.bold("\n Hook-Specific Docs\n"));
console.log(` hooks docs <name> View README for a specific hook`);
console.log(` hooks docs --json Machine-readable documentation`);
console.log(` hooks docs <name> Compact hook docs`);
console.log(` hooks docs <name> --verbose Full hook README`);
console.log(` hooks docs --json Machine-readable documentation`);
console.log();
});

Expand Down Expand Up @@ -977,11 +1022,11 @@ logCmd
console.log(chalk.bold(`\n Hook Events (${rows.length})\n`));
for (const row of rows) {
const ts = row.timestamp.slice(0, 19).replace("T", " ");
const err = row.error ? chalk.red(` ERR: ${row.error.slice(0, 60)}`) : "";
const err = row.error ? chalk.red(` ERR: ${truncateText(row.error, 60)}`) : "";
const tool = row.tool_name ? chalk.dim(` [${row.tool_name}]`) : "";
console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))}${tool}${err}`);
}
console.log();
console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or --limit <n> to change row count."));
});

logCmd
Expand All @@ -1004,10 +1049,10 @@ logCmd
console.log(chalk.bold(`\n Search results for "${text}" (${rows.length})\n`));
for (const row of rows) {
const ts = row.timestamp.slice(0, 19).replace("T", " ");
const snippet = (row.tool_input || row.error || "").slice(0, 80);
const snippet = truncateText(row.tool_input || row.error || "", 80);
console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))} ${chalk.dim(snippet)}`);
}
console.log();
console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or --limit <n> to change row count."));
});

logCmd
Expand All @@ -1029,11 +1074,11 @@ logCmd
console.log(chalk.bold(`\n Last ${rows.length} events\n`));
for (const row of rows) {
const ts = row.timestamp.slice(0, 19).replace("T", " ");
const err = row.error ? chalk.red(` ✗ ${row.error.slice(0, 60)}`) : "";
const err = row.error ? chalk.red(` ✗ ${truncateText(row.error, 60)}`) : "";
const tool = row.tool_name ? chalk.dim(` [${row.tool_name}]`) : "";
console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))}${tool}${err}`);
}
console.log();
console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or -n <n> to change row count."));
});

logCmd
Expand Down Expand Up @@ -1072,9 +1117,9 @@ logCmd
console.log(chalk.bold(`\n Errors (last ${options.since}, ${rows.length} found)\n`));
for (const row of rows) {
const ts = row.timestamp.slice(0, 19).replace("T", " ");
console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))} ${chalk.red(row.error.slice(0, 100))}`);
console.log(` ${chalk.dim(ts)} ${chalk.cyan(row.hook_name.padEnd(14))} ${chalk.red(truncateText(row.error, 100))}`);
}
console.log();
console.log(chalk.dim("\n Compact rows shown. Use --json for full event records or --limit <n> to change row count."));
});

logCmd
Expand Down
2 changes: 1 addition & 1 deletion src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Supports HASNA_HOOKS_DATA_DIR / HOOKS_DATA_DIR and HASNA_HOOKS_DB_PATH / HOOKS_DB_PATH env overrides.
*/

import { SqliteAdapter as Database } from "@hasna/cloud";
import { Database } from "bun:sqlite";
import { existsSync, mkdirSync, cpSync } from "fs";
import { join } from "path";
import { homedir } from "os";
Expand Down
1 change: 1 addition & 0 deletions src/lib/installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ describe("installer", () => {
projectBackup = null;
}
// Start each test with clean project settings
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
writeFileSync(projectSettings, "{}\n");
});

Expand Down
Loading
Loading