oh-my-claudecode
Splits a complex task across several specialized Claude Code agents working in parallel, then keeps going until the result is checked. You describe what you want in plain language instead of memorizing commands.
What goodable runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Claude Code
$ npx -y skills add imgoodbai/goodable --agent claude-code
Cursor
Cursor also runs Claude Code hooks from .claude/settings.json, so copy these hooks there to use them in Cursor.
Where it lives
/**
* CLI Hook
* Manages CLI configuration and status
*/
import { useState, useCallback, useEffect } from 'react';
import { CLIOption, CLIStatus, CLIPreference, CLI_OPTIONS } from '@/types/cli';
import { getDefaultModelForCli } from '@/lib/constants/cliModels';
import { DEFAULT_ACTIVE_CLI, normalizeModelForCli, sanitizeActiveCli } from '@/lib/utils/cliOptions';
interface UseCLIOptions {
projectId: string;
}
const buildOptimisticStatus = (): CLIStatus =>
CLI_OPTIONS.reduce((acc, option) => {
acc[option.id] = {
installed: true,
checking: false,
available: true,
configured: true,
models: option.models?.map((model) => model.id),
};
return acc;
}, {} as CLIStatus);
export const createCliStatusFallback = (): CLIStatus => buildOptimisticStatus();
export async function fetchCliStatusSnapshot(): Promise<CLIStatus> {
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? '';
try {
const response = await fetch(`${API_BASE}/api/settings/cli-status`);
if (!response.ok) {
throw new Error(`Failed to fetch CLI status: ${response.status}`);
}
const payload = (await response.json()) as CLIStatus;
const optimistic = buildOptimisticStatus();
for (const option of CLI_OPTIONS) {
const entry = payload[option.id];
if (!entry) {
continue;
}
optimistic[option.id] = {
...optimistic[option.id],
...entry,
checking: false,
available: entry.available ?? entry.installed ?? optimistic[option.id]?.available ?? false,
configured: entry.configured ?? entry.installed ?? optimistic[option.id]?.configured ?? false,
models: entry.models ?? option.models?.map((model) => model.id),
};
}
return optimistic;
} catch (error) {
console.warn('Failed to fetch CLI status from API:', error);
return buildOptimisticStatus();
}
}
export function useCLI({ projectId }: UseCLIOptions) {
const [cliOptions, setCLIOptions] = useState<CLIOption[]>(() => CLI_OPTIONS.map((option) => ({ ...option })));
const [preference, setPreference] = useState<CLIPreference | null>(null);
const [statuses, setStatuses] = useState<CLIStatus>(() => createCliStatusFallback());
const [isLoading, setIsLoading] = useState(false);
const parsePreference = useCallback((payload: unknown): CLIPreference => {
const data = payload as Record<string, unknown> | null | undefined;
const preferredRaw =
typeof data?.preferredCli === 'string'
? data.preferredCli
: typeof data?.preferred_cli === 'string'
? data.preferred_cli
: DEFAULT_ACTIVE_CLI;
const preferredCli = sanitizeActiveCli(preferredRaw, DEFAULT_ACTIVE_CLI);
const fallbackEnabled =
typeof data?.fallbackEnabled === 'boolean'
? data.fallbackEnabled
: typeof data?.fallback_enabled === 'boolean'
? data.fallback_enabled
: false;
const rawModel =
typeof data?.selectedModel === 'string'
? data.selectedModel
: typeof data?.selected_model === 'string'
? data.selected_model
: undefined;
const normalizedModel = normalizeModelForCli(preferredCli, rawModel, preferredCli);
return {
preferredCli,
fallbackEnabled,
selectedModel: normalizedModel || getDefaultModelForCli(preferredCli),
};
}, []);
// Load CLI preference
const loadPreference = useCallback(async () => {
try {
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? '';
const response = await fetch(`${API_BASE}/api/projects/${projectId}`);
if (!response.ok) {
throw new Error('Failed to load project preferences');
}
const payload = await response.json();
const project = payload?.data ?? payload ?? {};
setPreference(parsePreference(project));
} catch (error) {
console.error('Failed to load CLI preference:', error);
setPreference({
preferredCli: DEFAULT_ACTIVE_CLI,
fallbackEnabled: false,
selectedModel: getDefaultModelForCli(DEFAULT_ACTIVE_CLI),
});
}
}, [projectId, parsePreference]);
const applyStatusToState = useCallback((status: CLIStatus) => {
setStatuses(status);
setCLIOptions(
CLI_OPTIONS.map((option) => {
const entry = status[option.id];
return {
...option,
available: Boolean(entry?.available ?? entry?.installed ?? option.available),
configured: Boolean(entry?.configured ?? entry?.installed ?? option.configured),
};
})
);
}, []);
// Load all CLI statuses
const loadStatuses = useCallback(async () => {
try {
setIsLoading(true);
const status = await fetchCliStatusSnapshot();
applyStatusToState(status);
} finally {
setIsLoading(false);
}
}, [applyStatusToState]);
// Check single CLI status
const checkCLIStatus = useCallback(async (cliType: string) => {
const status = await fetchCliStatusSnapshot();
applyStatusToState(status);
return status[cliType];
}, [applyStatusToState]);
// Update CLI preference
const updatePreference = useCallback(async (preferredCliInput: string) => {
const sanitizedInput = sanitizeActiveCli(preferredCliInput, DEFAULT_ACTIVE_CLI);
try {
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? '';
const response = await fetch(`${API_BASE}/api/projects/${projectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preferredCli: sanitizedInput }),
});
if (!response.ok) throw new Error('Failed to update CLI preference');
const payload = await response.json();
const project = payload?.data ?? payload ?? {};
const responseCli = sanitizeActiveCli(
project.preferredCli ?? project.preferred_cli ?? sanitizedInput,
DEFAULT_ACTIVE_CLI
);
const rawSelected = project.selectedModel ?? project.selected_model;
setPreference("use client";
import { useState, useEffect } from 'react';
const SLIM_MODE_KEY = 'goodable_slim_mode';
const SLIM_PREVIOUS_SIZE_KEY = 'goodable_slim_previous_size';
const SLIM_MODE_EVENT = 'goodable-slim-mode-changed';
const SLIM_WIDTH = 420;
const SLIM_HEIGHT = 550;
const DEFAULT_WIDTH = 1280;
const DEFAULT_HEIGHT = 800;
const NORMAL_MIN_WIDTH = 1024;
const NORMAL_MIN_HEIGHT = 640;
/**
* Global hook for managing slim mode state.
* Slim mode only controls window size - layout adapts via CSS media queries.
*/
export function useSlimMode() {
const [isSlimMode, setIsSlimMode] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
// Initialize from localStorage
useEffect(() => {
if (typeof window === 'undefined') return;
const saved = localStorage.getItem(SLIM_MODE_KEY);
if (saved === 'true') {
setIsSlimMode(true);
}
setIsInitialized(true);
}, []);
// Listen for toggle event from title bar
useEffect(() => {
if (typeof window === 'undefined') return;
const handleTitleBarToggle = async () => {
const desktopAPI = (window as any).desktopAPI;
const currentSlimMode = localStorage.getItem(SLIM_MODE_KEY) === 'true';
const newSlimMode = !currentSlimMode;
// Update localStorage and state
localStorage.setItem(SLIM_MODE_KEY, String(newSlimMode));
setIsSlimMode(newSlimMode);
// Dispatch event for other listeners (like preload.js button state)
window.dispatchEvent(new CustomEvent(SLIM_MODE_EVENT, { detail: { isSlimMode: newSlimMode } }));
// Handle window resize
if (desktopAPI?.windowControls?.setSize && desktopAPI?.windowControls?.getSize) {
try {
if (newSlimMode) {
// Entering slim mode: save current size to localStorage and resize
const currentSize = await desktopAPI.windowControls.getSize();
if (currentSize.width > 500) {
localStorage.setItem(SLIM_PREVIOUS_SIZE_KEY, JSON.stringify(currentSize));
await desktopAPI.windowControls.setSize({
width: SLIM_WIDTH,
height: SLIM_HEIGHT,
minWidth: 400,
minHeight: 400
});
}
} else {
// Exiting slim mode: restore previous size from localStorage
const savedSize = localStorage.getItem(SLIM_PREVIOUS_SIZE_KEY);
if (savedSize) {
const previousSize = JSON.parse(savedSize);
await desktopAPI.windowControls.setSize({
width: previousSize.width,
height: previousSize.height,
minWidth: NORMAL_MIN_WIDTH,
minHeight: NORMAL_MIN_HEIGHT
});
localStorage.removeItem(SLIM_PREVIOUS_SIZE_KEY);
} else {
// No previous size, restore to default
await desktopAPI.windowControls.setSize({
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
minWidth: NORMAL_MIN_WIDTH,
minHeight: NORMAL_MIN_HEIGHT
});
}
}
} catch (e) {
console.error('Failed to resize window:', e);
}
}
};
window.addEventListener('electron-toggle-slim-mode', handleTitleBarToggle);
return () => {
window.removeEventListener('electron-toggle-slim-mode', handleTitleBarToggle);
};
}, []);
// Listen for changes from other tabs/windows
useEffect(() => {
if (typeof window === 'undefined') return;
const handleStorageChange = (e: StorageEvent) => {
if (e.key === SLIM_MODE_KEY) {
setIsSlimMode(e.newValue === 'true');
}
};
const handleCustomEvent = (e: Event) => {
const customEvent = e as CustomEvent<{ isSlimMode: boolean }>;
setIsSlimMode(customEvent.detail.isSlimMode);
};
window.addEventListener('storage', handleStorageChange);
window.addEventListener(SLIM_MODE_EVENT, handleCustomEvent);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener(SLIM_MODE_EVENT, handleCustomEvent);
};
}, []);
return {
isSlimMode,
isInitialized,
};
}
/**
* Simplified hook - just returns slim mode state.
* Window size is now handled within useSlimMode itself.
*/
export function useSlimModeWindowSize() {
const { isSlimMode } = useSlimMode();
return { isSlimMode };
}
/**
* WebSocket Hook
* Manages WebSocket connection for real-time updates
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { WEBSOCKET_CONFIG } from '@/lib/config/constants';
import type { ChatMessage, RealtimeEvent, RealtimeStatus } from '@/types';
interface WebSocketOptions {
projectId: string;
onMessage?: (message: ChatMessage) => void;
onStatus?: (status: string, data?: RealtimeStatus | Record<string, unknown>, requestId?: string) => void;
onConnect?: () => void;
onDisconnect?: () => void;
onError?: (error: Error) => void;
}
export function useWebSocket({
projectId,
onMessage,
onStatus,
onConnect,
onDisconnect,
onError
}: WebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const heartbeatIntervalRef = useRef<NodeJS.Timeout | null>(null);
const connectionAttemptsRef = useRef(0);
const shouldReconnectRef = useRef(true);
const manualCloseRef = useRef(false);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const handlersRef = useRef({
onMessage,
onStatus,
onConnect,
onDisconnect,
onError,
});
useEffect(() => {
handlersRef.current = {
onMessage,
onStatus,
onConnect,
onDisconnect,
onError,
};
}, [onMessage, onStatus, onConnect, onDisconnect, onError]);
const clearHeartbeat = useCallback(() => {
if (heartbeatIntervalRef.current) {
clearInterval(heartbeatIntervalRef.current);
heartbeatIntervalRef.current = null;
}
}, []);
const startHeartbeat = useCallback(() => {
clearHeartbeat();
heartbeatIntervalRef.current = setInterval(() => {
const socket = wsRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
try {
socket.send('ping');
} catch (error) {
console.error('Failed to send WebSocket ping:', error);
}
}, 25000);
}, [clearHeartbeat]);
const connect = useCallback(() => {
const existing = wsRef.current;
if (existing) {
if (
existing.readyState === WebSocket.OPEN ||
existing.readyState === WebSocket.CONNECTING
) {
return;
}
try {
existing.close(1000, 'Reconnecting');
} catch {
// Ignore close errors; we'll replace the socket below.
}
wsRef.current = null;
}
// Don't reconnect if we're intentionally disconnecting
if (!shouldReconnectRef.current) {
return;
}
const resolveWebSocketUrl = () => {
const rawBase = process.env.NEXT_PUBLIC_WS_BASE?.trim() ?? '';
const endpoint = `/api/ws/${projectId}`;
if (rawBase.length > 0) {
const normalizedBase = rawBase.replace(/\/+$/, '');
return `${normalizedBase}${endpoint}`;
}
if (typeof window !== 'undefined') {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}${endpoint}`;
}
throw new Error('WebSocket base URL is not available');
};
const resolveHttpWarmupUrl = () => {
const rawBase = process.env.NEXT_PUBLIC_WS_BASE?.trim() ?? '';
const endpoint = `/api/ws/${projectId}`;
if (rawBase.length > 0) {
// Convert ws/wss to http/https for the warm-up fetch
const normalizedBase = rawBase
.replace(/\/+$/, '')
.replace(/^ws:\/\//i, 'http://')
.replace(/^wss:\/\//i, 'https://');
return `${normalizedBase}${endpoint}`;
}
if (typeof window !== 'undefined') {
const httpProto = window.location.protocol === 'https:' ? 'https:' : 'http:';
return `${httpProto}//${window.location.host}${endpoint}`;
}
throw new Error('HTTP base URL is not available');
};
const openWebSocket = () => {
setIsConnecting(true);
const ws = new WebSocket(resolveWebSocketUrl());
manualCloseRef.current = false;
ws.onopen = () => {
setIsConnected(true);
setIsConnecting(false);
connectionAttemptsRef.current = 0;
startHeartbeat();
handlersRef.current.onConnect?.();
};
ws.onmessage = (event) => {
if (event.data === 'pong') {
return;
}
try {
const envelope = JSON.parse(event.data) as RealtimeEvent;
const { onMessage: handleMessage, onStatus: handleStatus, onError: handleError } =
handlersRef.current;
switch (envelope.type) {
case 'message':
if (envelope.data && handleMessage) {
handleMessage(envelope.data);
}
break;
case 'status':
if (envelope.data && handleStatus) {
handleStatus(envelope.data.status, envelope.data, envelope.data.requestId);
}
break;
case 'preview_status':
console.log(`====安装预览 ### [frontend] preview_status event received, status=${envelope.data?.status}`, envelope.data);
if (envelope.data && handleStatus) {
handleStatus(envelope.data.status, envelope.data, envelope.data.requestId);
}
break;
case 'error': {
const message = envelope.error ?? 'Realtime bridge error';
const rawData = envelope.data as Record<string, unknown> | undefined;
const requestId = (() => {
if (!rawData) return undefined;
const direct = rawData.requestId ?? rawData.request_id;
return typeof direct === 'string' ? direct : undefined;
})();
const payload: RealtimeStatus = {
status: 'error',
message,
...(requestId ? { requestId } : {}),
};
handleStatus?.('error', payRead the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Goodable is a local-first Desktop AI Workspace (Desktop Agent Runtime) powered by Claude Agent SDK, combining OS/file control, browser automation, and coding into composable Skills (tool + app modes) to build, run, and publish real AI apps.
Splits a complex task across several specialized Claude Code agents working in parallel, then keeps going until the result is checked. You describe what you want in plain language instead of memorizing commands.
Turns a rough feature idea into a written spec through guided questions, then has the agent build it and checks the result against that spec. Instead of hand-checking whether the work is finished, you get an automatic evaluation gate that runs in your coding agent.
A plugin that runs coding tasks through multiple AI backends (Codex, Claude, Gemini, OpenCode) with a structured development workflow, parallel execution, browser automation and multi-session task checkpointing.
Coordinates AI agents with mechanical protocol enforcement, a workflow gate, memory validation and a session hook. Includes a catalog of specialist agents and has no runtime dependencies.
Bernstein is a Claude Code plugin for orchestrating AI agents. It adds skills to list and inspect agents, review and approve tasks, create tasks, track costs and budgets, and show alerts for failed or stalled work.
A Claude Code plugin that enforces finishing multi-step tasks with evidence and verification at each stage. It works as a procedure for Opus or any Claude model, not as chance.
Adjusts how third-party models behave inside Claude Code so they run closer to a native Opus session, covering subagent lifecycle, session hooks and tool interception. It does not replace your gateway, provider mapping or account setup.
Runs a PDCA workflow with phase gating, spec checks, and delegation to sub-agents for planning, QA, and deployment. Fires at session start and on prompts, edits, task creation, and phase transitions.