2025-12-07 19:32:53 +02:00
|
|
|
import { create } from "zustand";
|
|
|
|
|
import type { StoreApi, UseBoundStore } from "zustand";
|
2026-06-30 04:47:52 -04:00
|
|
|
import { devtools, persist } from "zustand/middleware";
|
2025-12-07 19:32:53 +02:00
|
|
|
import { opencodeClient } from "@/lib/opencode/client";
|
|
|
|
|
import {
|
|
|
|
|
startConfigUpdate,
|
|
|
|
|
finishConfigUpdate,
|
|
|
|
|
updateConfigUpdateMessage,
|
|
|
|
|
} from "@/lib/configUpdate";
|
|
|
|
|
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
|
2026-06-30 04:47:52 -04:00
|
|
|
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
|
2026-01-06 21:31:04 +02:00
|
|
|
import { useProjectsStore } from "@/stores/useProjectsStore";
|
2026-06-02 00:43:05 +03:00
|
|
|
import { runtimeFetch } from "@/lib/runtime-fetch";
|
2026-07-31 12:51:15 +03:00
|
|
|
import { runBackgroundNetworkTask } from '@/lib/background-network';
|
2026-08-03 06:56:38 +00:00
|
|
|
import { noteDeferredRestartFromPayload } from "@/lib/opencode/deferredRestart";
|
2026-01-06 21:31:04 +02:00
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
|
|
|
|
|
export type CommandScope = 'user' | 'project';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
export interface CommandConfig {
|
|
|
|
|
name: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
agent?: string | null;
|
|
|
|
|
model?: string | null;
|
2026-05-17 00:47:51 +03:00
|
|
|
source?: string;
|
2025-12-07 19:32:53 +02:00
|
|
|
template?: string;
|
2025-12-29 17:48:06 +02:00
|
|
|
scope?: CommandScope;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface Command extends CommandConfig {
|
|
|
|
|
isBuiltIn?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
// Built-in commands provided by OpenCode (not defined in user config directories)
|
|
|
|
|
const BUILTIN_COMMAND_NAMES = new Set(['init', 'review']);
|
|
|
|
|
|
|
|
|
|
export const isCommandBuiltIn = (command: Command): boolean => {
|
|
|
|
|
return BUILTIN_COMMAND_NAMES.has(command.name);
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const CONFIG_EVENT_SOURCE = "useCommandsStore";
|
|
|
|
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
2026-03-20 01:01:03 +02:00
|
|
|
const COMMANDS_LOAD_CACHE_TTL_MS = 5000;
|
|
|
|
|
const DEFAULT_COMMANDS_CACHE_KEY = '__default__';
|
|
|
|
|
const commandsLastLoadedAt = new Map<string, number>();
|
|
|
|
|
const commandsLoadInFlight = new Map<string, Promise<boolean>>();
|
|
|
|
|
|
|
|
|
|
const getCommandsCacheKey = (directory: string | null): string => {
|
|
|
|
|
return directory?.trim() || DEFAULT_COMMANDS_CACHE_KEY;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-08 13:43:45 +03:00
|
|
|
export const invalidateCommandsLoadCache = (directory: string | null = getRequestDirectory()) => {
|
|
|
|
|
commandsLastLoadedAt.delete(getCommandsCacheKey(directory));
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
const buildCommandsSignature = (commands: Command[]): string => {
|
|
|
|
|
return commands
|
|
|
|
|
.map((command) => [
|
|
|
|
|
command.name,
|
|
|
|
|
command.scope ?? '',
|
|
|
|
|
command.description ?? '',
|
|
|
|
|
command.agent ?? '',
|
|
|
|
|
command.model ?? '',
|
|
|
|
|
String(command.isBuiltIn === true),
|
|
|
|
|
].join('|'))
|
|
|
|
|
.join('||');
|
|
|
|
|
};
|
2026-01-06 21:31:04 +02:00
|
|
|
|
2026-08-03 06:56:38 +00:00
|
|
|
const upsertCommandLocal = (
|
2026-08-22 19:50:16 +03:00
|
|
|
set: (updater: (state: CommandsStore) => Partial<CommandsStore>) => void,
|
2026-08-03 06:56:38 +00:00
|
|
|
get: () => CommandsStore,
|
|
|
|
|
name: string,
|
|
|
|
|
config: Partial<CommandConfig>,
|
2026-08-22 19:50:16 +03:00
|
|
|
directory: string | null,
|
2026-08-03 06:56:38 +00:00
|
|
|
) => {
|
2026-08-22 19:50:16 +03:00
|
|
|
const cacheKey = getCommandsCacheKey(directory);
|
|
|
|
|
const isAmbient = cacheKey === getCommandsCacheKey(getRequestDirectory());
|
|
|
|
|
const current = get().commandsByDirectory[cacheKey] ?? [];
|
|
|
|
|
const existing = current.find((command) => command.name === name);
|
2026-08-03 06:56:38 +00:00
|
|
|
const nextCommand: Command = {
|
|
|
|
|
...existing,
|
|
|
|
|
name,
|
|
|
|
|
...config,
|
|
|
|
|
source: config.source ?? existing?.source,
|
|
|
|
|
scope: config.scope ?? existing?.scope,
|
|
|
|
|
isBuiltIn: existing?.isBuiltIn,
|
|
|
|
|
};
|
2026-08-22 19:50:16 +03:00
|
|
|
const nextCommands = current.some((command) => command.name === name)
|
|
|
|
|
? current.map((command) => (command.name === name ? nextCommand : command))
|
|
|
|
|
: [...current, nextCommand];
|
|
|
|
|
set((state) => {
|
|
|
|
|
const next: Partial<CommandsStore> = {
|
|
|
|
|
commandsByDirectory: { ...state.commandsByDirectory, [cacheKey]: nextCommands },
|
|
|
|
|
};
|
|
|
|
|
if (isAmbient) next.commands = nextCommands;
|
|
|
|
|
return next;
|
|
|
|
|
});
|
2026-08-03 06:56:38 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removeCommandLocal = (
|
2026-08-22 19:50:16 +03:00
|
|
|
set: (updater: (state: CommandsStore) => Partial<CommandsStore>) => void,
|
2026-08-03 06:56:38 +00:00
|
|
|
get: () => CommandsStore,
|
|
|
|
|
name: string,
|
2026-08-22 19:50:16 +03:00
|
|
|
directory: string | null,
|
2026-08-03 06:56:38 +00:00
|
|
|
) => {
|
2026-08-22 19:50:16 +03:00
|
|
|
const cacheKey = getCommandsCacheKey(directory);
|
|
|
|
|
const isAmbient = cacheKey === getCommandsCacheKey(getRequestDirectory());
|
|
|
|
|
const nextCommands = (get().commandsByDirectory[cacheKey] ?? []).filter((command) => command.name !== name);
|
|
|
|
|
const clearSelection = get().selectedCommandName === name;
|
|
|
|
|
set((state) => {
|
|
|
|
|
const next: Partial<CommandsStore> = {
|
|
|
|
|
commandsByDirectory: { ...state.commandsByDirectory, [cacheKey]: nextCommands },
|
|
|
|
|
};
|
|
|
|
|
if (isAmbient) next.commands = nextCommands;
|
|
|
|
|
if (clearSelection) next.selectedCommandName = null;
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Directory a call operates on. Settings can browse another project without
|
|
|
|
|
* moving the app, so every entry point takes one; omitting it means the project
|
|
|
|
|
* the app is currently on.
|
|
|
|
|
*/
|
|
|
|
|
const resolveDirectory = (directory?: string | null): string | null => {
|
|
|
|
|
if (directory !== undefined) {
|
|
|
|
|
const trimmed = directory?.trim();
|
|
|
|
|
return trimmed ? trimmed : null;
|
2026-08-03 06:56:38 +00:00
|
|
|
}
|
2026-08-22 19:50:16 +03:00
|
|
|
return getRequestDirectory();
|
2026-08-03 06:56:38 +00:00
|
|
|
};
|
|
|
|
|
|
2026-01-06 21:31:04 +02:00
|
|
|
const getRequestDirectory = (): string | null => {
|
|
|
|
|
try {
|
|
|
|
|
const projectsStore = useProjectsStore.getState();
|
|
|
|
|
const activeProject = projectsStore.getActiveProject?.();
|
|
|
|
|
|
|
|
|
|
// 1. Primary: Active project path from store
|
|
|
|
|
if (activeProject?.path?.trim()) {
|
|
|
|
|
return activeProject.path.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Fallback: current OpenCode directory (session / runtime)
|
|
|
|
|
const clientDir = opencodeClient.getDirectory();
|
|
|
|
|
if (clientDir?.trim()) {
|
|
|
|
|
return clientDir.trim();
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn('[CommandsStore] Error resolving config directory:', err);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const MAX_HEALTH_WAIT_MS = 20000;
|
|
|
|
|
const FAST_HEALTH_POLL_INTERVAL_MS = 300;
|
|
|
|
|
const FAST_HEALTH_POLL_ATTEMPTS = 4;
|
|
|
|
|
const SLOW_HEALTH_POLL_BASE_MS = 800;
|
|
|
|
|
const SLOW_HEALTH_POLL_INCREMENT_MS = 200;
|
|
|
|
|
const SLOW_HEALTH_POLL_MAX_MS = 2000;
|
|
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
export interface CommandDraft {
|
|
|
|
|
name: string;
|
|
|
|
|
scope: CommandScope;
|
|
|
|
|
description?: string;
|
|
|
|
|
agent?: string | null;
|
|
|
|
|
model?: string | null;
|
|
|
|
|
template?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
interface CommandsStore {
|
|
|
|
|
|
|
|
|
|
selectedCommandName: string | null;
|
2026-08-22 19:50:16 +03:00
|
|
|
/** Commands of the project the app is on. Chat and autocompletes read this one. */
|
2025-12-07 19:32:53 +02:00
|
|
|
commands: Command[];
|
2026-08-22 19:50:16 +03:00
|
|
|
/** Every directory loaded so far, including the ambient one. */
|
|
|
|
|
commandsByDirectory: Record<string, Command[]>;
|
2025-12-07 19:32:53 +02:00
|
|
|
isLoading: boolean;
|
2025-12-29 17:48:06 +02:00
|
|
|
commandDraft: CommandDraft | null;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
setSelectedCommand: (name: string | null) => void;
|
2025-12-29 17:48:06 +02:00
|
|
|
setCommandDraft: (draft: CommandDraft | null) => void;
|
2026-08-22 19:50:16 +03:00
|
|
|
loadCommands: (directory?: string | null) => Promise<boolean>;
|
|
|
|
|
createCommand: (config: CommandConfig, directory?: string | null) => Promise<boolean>;
|
|
|
|
|
updateCommand: (name: string, config: Partial<CommandConfig>, directory?: string | null) => Promise<boolean>;
|
|
|
|
|
deleteCommand: (name: string, directory?: string | null) => Promise<boolean>;
|
|
|
|
|
getCommandByName: (name: string, directory?: string | null) => Command | undefined;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
|
interface Window {
|
|
|
|
|
__zustand_commands_store__?: UseBoundStore<StoreApi<CommandsStore>>;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 19:50:16 +03:00
|
|
|
const EMPTY_COMMANDS: Command[] = [];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Commands of one project. Returns a stored array so components can select it
|
|
|
|
|
* directly; an omitted directory means the project the app is on.
|
|
|
|
|
*/
|
|
|
|
|
export const selectCommandsForDirectory = (
|
|
|
|
|
state: Pick<CommandsStore, 'commandsByDirectory'>,
|
|
|
|
|
directory?: string | null,
|
|
|
|
|
): Command[] => {
|
|
|
|
|
const cacheKey = getCommandsCacheKey(resolveDirectory(directory));
|
|
|
|
|
return state.commandsByDirectory[cacheKey] ?? EMPTY_COMMANDS;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
export const useCommandsStore = create<CommandsStore>()(
|
|
|
|
|
devtools(
|
|
|
|
|
persist(
|
|
|
|
|
(set, get) => ({
|
|
|
|
|
|
|
|
|
|
selectedCommandName: null,
|
|
|
|
|
commands: [],
|
2026-08-22 19:50:16 +03:00
|
|
|
commandsByDirectory: {},
|
2025-12-07 19:32:53 +02:00
|
|
|
isLoading: false,
|
2025-12-29 17:48:06 +02:00
|
|
|
commandDraft: null,
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
setSelectedCommand: (name: string | null) => {
|
|
|
|
|
set({ selectedCommandName: name });
|
|
|
|
|
},
|
|
|
|
|
|
2025-12-29 17:48:06 +02:00
|
|
|
setCommandDraft: (draft: CommandDraft | null) => {
|
|
|
|
|
set({ commandDraft: draft });
|
|
|
|
|
},
|
|
|
|
|
|
2026-08-22 19:50:16 +03:00
|
|
|
loadCommands: async (requestedDirectory?: string | null) => {
|
|
|
|
|
const directory = resolveDirectory(requestedDirectory);
|
2026-03-20 01:01:03 +02:00
|
|
|
const cacheKey = getCommandsCacheKey(directory);
|
2026-08-22 19:50:16 +03:00
|
|
|
const isAmbient = cacheKey === getCommandsCacheKey(getRequestDirectory());
|
2026-03-20 01:01:03 +02:00
|
|
|
const now = Date.now();
|
|
|
|
|
const loadedAt = commandsLastLoadedAt.get(cacheKey) ?? 0;
|
2026-08-22 19:50:16 +03:00
|
|
|
const hasCachedCommands = (get().commandsByDirectory[cacheKey] ?? (isAmbient ? get().commands : [])).length > 0;
|
2026-03-20 01:01:03 +02:00
|
|
|
|
|
|
|
|
if (hasCachedCommands && now - loadedAt < COMMANDS_LOAD_CACHE_TTL_MS) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-01-06 21:31:04 +02:00
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
const inFlight = commandsLoadInFlight.get(cacheKey);
|
|
|
|
|
if (inFlight) {
|
|
|
|
|
return inFlight;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const request = (async () => {
|
|
|
|
|
set({ isLoading: true });
|
2026-08-22 19:50:16 +03:00
|
|
|
// Failure must never look like an empty project. The mirror is the
|
|
|
|
|
// fallback so a directory loaded before this map existed still counts.
|
|
|
|
|
const previousCommands = get().commandsByDirectory[cacheKey] ?? (isAmbient ? get().commands : []);
|
2026-03-20 01:01:03 +02:00
|
|
|
const previousSignature = buildCommandsSignature(previousCommands);
|
|
|
|
|
let lastError: unknown = null;
|
|
|
|
|
|
|
|
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
|
|
|
try {
|
|
|
|
|
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
|
|
|
|
|
|
|
|
|
// Ensure the list is scoped to the same directory we use for config source detection.
|
2026-07-31 12:51:15 +03:00
|
|
|
const commands = await runBackgroundNetworkTask(() => opencodeClient.withDirectory(
|
2026-03-20 01:01:03 +02:00
|
|
|
directory,
|
|
|
|
|
() => opencodeClient.listCommandsWithDetails()
|
2026-07-31 12:51:15 +03:00
|
|
|
));
|
2026-03-20 01:01:03 +02:00
|
|
|
|
2026-05-17 00:47:51 +03:00
|
|
|
const configurableCommands = commands.filter((cmd) => cmd.source !== 'skill');
|
2026-03-20 01:01:03 +02:00
|
|
|
const commandsWithScope = await Promise.all(
|
2026-05-17 00:47:51 +03:00
|
|
|
configurableCommands.map(async (cmd) => {
|
2026-03-20 01:01:03 +02:00
|
|
|
try {
|
|
|
|
|
// Force no-cache
|
2026-06-02 00:43:05 +03:00
|
|
|
const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`, {
|
2026-03-20 01:01:03 +02:00
|
|
|
headers: {
|
|
|
|
|
'Cache-Control': 'no-cache',
|
|
|
|
|
...(directory ? { 'x-opencode-directory': directory } : {}),
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
// Prioritize explicit scope
|
|
|
|
|
let scope = data.scope;
|
|
|
|
|
|
|
|
|
|
// Fallback to deducing from sources
|
|
|
|
|
if (!scope && data.sources) {
|
|
|
|
|
const sources = data.sources;
|
|
|
|
|
scope = (sources.md?.exists ? sources.md.scope : undefined)
|
|
|
|
|
?? (sources.json?.exists ? sources.json.scope : undefined)
|
|
|
|
|
?? sources.md?.scope
|
|
|
|
|
?? sources.json?.scope;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (scope === 'project' || scope === 'user') {
|
|
|
|
|
return { ...cmd, scope: scope as CommandScope };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Explicitly set null scope if not found
|
|
|
|
|
return { ...cmd, scope: undefined };
|
2026-01-06 21:31:04 +02:00
|
|
|
}
|
2026-03-20 01:01:03 +02:00
|
|
|
} catch (err) {
|
|
|
|
|
console.warn(`[CommandsStore] Failed to fetch config for command ${cmd.name}:`, err);
|
2025-12-29 17:48:06 +02:00
|
|
|
}
|
2026-03-20 01:01:03 +02:00
|
|
|
return cmd;
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const nextSignature = buildCommandsSignature(commandsWithScope);
|
|
|
|
|
if (previousSignature !== nextSignature) {
|
2026-08-22 19:50:16 +03:00
|
|
|
set((state) => {
|
|
|
|
|
const next: Partial<CommandsStore> = {
|
|
|
|
|
commandsByDirectory: { ...state.commandsByDirectory, [cacheKey]: commandsWithScope },
|
|
|
|
|
isLoading: false,
|
|
|
|
|
};
|
|
|
|
|
if (isAmbient) next.commands = commandsWithScope;
|
|
|
|
|
return next;
|
|
|
|
|
});
|
2026-03-20 01:01:03 +02:00
|
|
|
} else {
|
|
|
|
|
set({ isLoading: false });
|
|
|
|
|
}
|
|
|
|
|
commandsLastLoadedAt.set(cacheKey, Date.now());
|
|
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
lastError = error;
|
|
|
|
|
const waitMs = 200 * (attempt + 1);
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
2026-01-06 21:31:04 +02:00
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 01:01:03 +02:00
|
|
|
console.error("Failed to load commands:", lastError);
|
2026-08-22 19:50:16 +03:00
|
|
|
set((state) => {
|
|
|
|
|
const next: Partial<CommandsStore> = {
|
|
|
|
|
commandsByDirectory: { ...state.commandsByDirectory, [cacheKey]: previousCommands },
|
|
|
|
|
isLoading: false,
|
|
|
|
|
};
|
|
|
|
|
if (isAmbient) next.commands = previousCommands;
|
|
|
|
|
return next;
|
|
|
|
|
});
|
2026-03-20 01:01:03 +02:00
|
|
|
return false;
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
commandsLoadInFlight.set(cacheKey, request);
|
|
|
|
|
try {
|
|
|
|
|
return await request;
|
|
|
|
|
} finally {
|
|
|
|
|
commandsLoadInFlight.delete(cacheKey);
|
|
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
},
|
|
|
|
|
|
2026-08-22 19:50:16 +03:00
|
|
|
createCommand: async (config: CommandConfig, requestedDirectory?: string | null) => {
|
2025-12-07 19:32:53 +02:00
|
|
|
try {
|
|
|
|
|
console.log('[CommandsStore] Creating command:', config.name);
|
|
|
|
|
|
|
|
|
|
const commandConfig: Record<string, unknown> = {
|
|
|
|
|
template: config.template || '',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (config.description) commandConfig.description = config.description;
|
|
|
|
|
if (config.agent) commandConfig.agent = config.agent;
|
|
|
|
|
if (config.model) commandConfig.model = config.model;
|
2025-12-29 17:48:06 +02:00
|
|
|
if (config.scope) commandConfig.scope = config.scope;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
console.log('[CommandsStore] Command config to save:', commandConfig);
|
|
|
|
|
|
2026-08-22 19:50:16 +03:00
|
|
|
const directory = resolveDirectory(requestedDirectory);
|
2026-01-06 21:31:04 +02:00
|
|
|
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
2025-12-29 17:48:06 +02:00
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(config.name)}${queryParams}`, {
|
2025-12-07 19:32:53 +02:00
|
|
|
method: 'POST',
|
2026-01-06 21:31:04 +02:00
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
...(directory ? { 'x-opencode-directory': directory } : {}),
|
|
|
|
|
},
|
2025-12-07 19:32:53 +02:00
|
|
|
body: JSON.stringify(commandConfig)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const payload = await response.json().catch(() => null);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const message = payload?.error || 'Failed to create command';
|
|
|
|
|
throw new Error(message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('[CommandsStore] Command created successfully');
|
|
|
|
|
|
2026-06-08 13:43:45 +03:00
|
|
|
invalidateCommandsLoadCache(directory);
|
2026-08-03 06:56:38 +00:00
|
|
|
|
|
|
|
|
if (payload?.requiresManualRestart) {
|
2026-08-22 19:50:16 +03:00
|
|
|
upsertCommandLocal(set, get, config.name, config, directory);
|
2026-08-03 06:56:38 +00:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (noteDeferredRestartFromPayload(payload, 'commands', { id: config.name })) {
|
2026-08-22 19:50:16 +03:00
|
|
|
upsertCommandLocal(set, get, config.name, config, directory);
|
2026-08-03 06:56:38 +00:00
|
|
|
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (payload?.requiresReload) {
|
|
|
|
|
startConfigUpdate("Creating command configuration…");
|
2025-12-07 19:32:53 +02:00
|
|
|
await performFullConfigRefresh({
|
|
|
|
|
message: payload?.message,
|
|
|
|
|
delayMs: payload?.reloadDelayMs,
|
|
|
|
|
});
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 19:50:16 +03:00
|
|
|
const loaded = await get().loadCommands(directory);
|
2025-12-07 19:32:53 +02:00
|
|
|
if (loaded) {
|
|
|
|
|
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
|
|
|
|
|
}
|
|
|
|
|
return loaded;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("[CommandsStore] Failed to create command:", error);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-08-22 19:50:16 +03:00
|
|
|
updateCommand: async (name: string, config: Partial<CommandConfig>, requestedDirectory?: string | null) => {
|
2025-12-07 19:32:53 +02:00
|
|
|
try {
|
|
|
|
|
console.log('[CommandsStore] Updating command:', name);
|
|
|
|
|
console.log('[CommandsStore] Config received:', config);
|
|
|
|
|
|
|
|
|
|
const commandConfig: Record<string, unknown> = {};
|
|
|
|
|
|
|
|
|
|
if (config.description !== undefined) commandConfig.description = config.description;
|
|
|
|
|
if (config.agent !== undefined) commandConfig.agent = config.agent;
|
|
|
|
|
if (config.model !== undefined) commandConfig.model = config.model;
|
|
|
|
|
if (config.template !== undefined) commandConfig.template = config.template;
|
|
|
|
|
|
|
|
|
|
console.log('[CommandsStore] Command config to update:', commandConfig);
|
|
|
|
|
|
2026-08-22 19:50:16 +03:00
|
|
|
const directory = resolveDirectory(requestedDirectory);
|
2026-01-06 21:31:04 +02:00
|
|
|
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
2025-12-29 17:48:06 +02:00
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
|
2025-12-07 19:32:53 +02:00
|
|
|
method: 'PATCH',
|
2026-01-06 21:31:04 +02:00
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
...(directory ? { 'x-opencode-directory': directory } : {}),
|
|
|
|
|
},
|
2025-12-07 19:32:53 +02:00
|
|
|
body: JSON.stringify(commandConfig)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const payload = await response.json().catch(() => null);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const message = payload?.error || 'Failed to update command';
|
|
|
|
|
throw new Error(message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('[CommandsStore] Command updated successfully');
|
|
|
|
|
|
2026-06-08 13:43:45 +03:00
|
|
|
invalidateCommandsLoadCache(directory);
|
2026-08-03 06:56:38 +00:00
|
|
|
|
|
|
|
|
if (payload?.requiresManualRestart) {
|
2026-08-22 19:50:16 +03:00
|
|
|
upsertCommandLocal(set, get, name, config, directory);
|
2026-08-03 06:56:38 +00:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (noteDeferredRestartFromPayload(payload, 'commands', { id: name })) {
|
2026-08-22 19:50:16 +03:00
|
|
|
upsertCommandLocal(set, get, name, config, directory);
|
2026-08-03 06:56:38 +00:00
|
|
|
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (payload?.requiresReload) {
|
|
|
|
|
startConfigUpdate("Updating command configuration…");
|
2025-12-07 19:32:53 +02:00
|
|
|
await performFullConfigRefresh({
|
|
|
|
|
message: payload?.message,
|
|
|
|
|
delayMs: payload?.reloadDelayMs,
|
|
|
|
|
});
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 19:50:16 +03:00
|
|
|
const loaded = await get().loadCommands(directory);
|
2025-12-07 19:32:53 +02:00
|
|
|
if (loaded) {
|
|
|
|
|
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
|
|
|
|
|
}
|
|
|
|
|
return loaded;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("[CommandsStore] Failed to update command:", error);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-08-22 19:50:16 +03:00
|
|
|
deleteCommand: async (name: string, requestedDirectory?: string | null) => {
|
2025-12-07 19:32:53 +02:00
|
|
|
try {
|
2026-01-06 21:31:04 +02:00
|
|
|
// Use active project root for project-level command support
|
2026-08-22 19:50:16 +03:00
|
|
|
const directory = resolveDirectory(requestedDirectory);
|
2026-01-06 21:31:04 +02:00
|
|
|
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
2025-12-29 17:48:06 +02:00
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const response = await runtimeFetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
|
2026-01-06 21:31:04 +02:00
|
|
|
method: 'DELETE',
|
|
|
|
|
headers: directory ? { 'x-opencode-directory': directory } : undefined,
|
2025-12-07 19:32:53 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const payload = await response.json().catch(() => null);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const message = payload?.error || 'Failed to delete command';
|
|
|
|
|
throw new Error(message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('[CommandsStore] Command deleted successfully');
|
|
|
|
|
|
2026-06-08 13:43:45 +03:00
|
|
|
invalidateCommandsLoadCache(directory);
|
2026-08-03 06:56:38 +00:00
|
|
|
|
|
|
|
|
if (payload?.requiresManualRestart) {
|
2026-08-22 19:50:16 +03:00
|
|
|
removeCommandLocal(set, get, name, directory);
|
2026-08-03 06:56:38 +00:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (noteDeferredRestartFromPayload(payload, 'commands', { id: name })) {
|
2026-08-22 19:50:16 +03:00
|
|
|
removeCommandLocal(set, get, name, directory);
|
2026-08-03 06:56:38 +00:00
|
|
|
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (payload?.requiresReload) {
|
|
|
|
|
startConfigUpdate("Deleting command configuration…");
|
2025-12-07 19:32:53 +02:00
|
|
|
await performFullConfigRefresh({
|
|
|
|
|
message: payload?.message,
|
|
|
|
|
delayMs: payload?.reloadDelayMs,
|
|
|
|
|
});
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 19:50:16 +03:00
|
|
|
const loaded = await get().loadCommands(directory);
|
2025-12-07 19:32:53 +02:00
|
|
|
if (loaded) {
|
|
|
|
|
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (get().selectedCommandName === name) {
|
|
|
|
|
set({ selectedCommandName: null });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return loaded;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Failed to delete command:", error);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-08-22 19:50:16 +03:00
|
|
|
getCommandByName: (name: string, requestedDirectory?: string | null) => {
|
|
|
|
|
return selectCommandsForDirectory(get(), requestedDirectory).find((command) => command.name === name);
|
2025-12-07 19:32:53 +02:00
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
name: "commands-store",
|
2026-06-30 04:47:52 -04:00
|
|
|
storage: createDeferredSafeJSONStorage(),
|
2025-12-07 19:32:53 +02:00
|
|
|
partialize: (state) => ({
|
|
|
|
|
selectedCommandName: state.selectedCommandName,
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
{
|
|
|
|
|
name: "commands-store",
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
|
window.__zustand_commands_store__ = useCommandsStore;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForOpenCodeConnection(delayMs?: number) {
|
|
|
|
|
const initialPause = typeof delayMs === "number" && delayMs > 0
|
|
|
|
|
? Math.min(delayMs, FAST_HEALTH_POLL_INTERVAL_MS)
|
|
|
|
|
: 0;
|
|
|
|
|
|
|
|
|
|
if (initialPause > 0) {
|
|
|
|
|
await sleep(initialPause);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const start = Date.now();
|
|
|
|
|
let attempt = 0;
|
|
|
|
|
let lastError: unknown = null;
|
|
|
|
|
|
|
|
|
|
while (Date.now() - start < MAX_HEALTH_WAIT_MS) {
|
|
|
|
|
attempt += 1;
|
|
|
|
|
updateConfigUpdateMessage(`Waiting for OpenCode… (attempt ${attempt})`);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const isHealthy = await opencodeClient.checkHealth();
|
|
|
|
|
if (isHealthy) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
lastError = new Error("OpenCode health check reported not ready");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
lastError = error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const elapsed = Date.now() - start;
|
|
|
|
|
|
|
|
|
|
const waitMs =
|
|
|
|
|
attempt <= FAST_HEALTH_POLL_ATTEMPTS && elapsed < 1200
|
|
|
|
|
? FAST_HEALTH_POLL_INTERVAL_MS
|
|
|
|
|
: Math.min(
|
|
|
|
|
SLOW_HEALTH_POLL_BASE_MS +
|
|
|
|
|
Math.max(0, attempt - FAST_HEALTH_POLL_ATTEMPTS) * SLOW_HEALTH_POLL_INCREMENT_MS,
|
|
|
|
|
SLOW_HEALTH_POLL_MAX_MS,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await sleep(waitMs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw lastError || new Error("OpenCode did not become ready in time");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function performFullConfigRefresh(options: { message?: string; delayMs?: number } = {}) {
|
|
|
|
|
const { message, delayMs } = options;
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-06 21:31:04 +02:00
|
|
|
updateConfigUpdateMessage(message || "Refreshing commands…");
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await waitForOpenCodeConnection(delayMs);
|
2026-01-06 21:31:04 +02:00
|
|
|
updateConfigUpdateMessage("Refreshing commands…");
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const commandsStore = useCommandsStore.getState();
|
|
|
|
|
|
2026-06-08 13:43:45 +03:00
|
|
|
invalidateCommandsLoadCache();
|
2026-01-06 21:31:04 +02:00
|
|
|
await commandsStore.loadCommands();
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
emitConfigChange("commands", { source: CONFIG_EVENT_SOURCE });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("[CommandsStore] Failed to refresh configuration after OpenCode restart:", error);
|
2026-01-06 21:31:04 +02:00
|
|
|
updateConfigUpdateMessage("OpenCode refresh failed. Please retry refreshing configuration manually.");
|
2025-12-07 19:32:53 +02:00
|
|
|
await sleep(1500);
|
2026-06-08 13:43:45 +03:00
|
|
|
throw error;
|
2025-12-07 19:32:53 +02:00
|
|
|
} finally {
|
|
|
|
|
finishConfigUpdate();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let unsubscribeCommandsConfigChanges: (() => void) | null = null;
|
|
|
|
|
|
|
|
|
|
if (!unsubscribeCommandsConfigChanges) {
|
|
|
|
|
unsubscribeCommandsConfigChanges = subscribeToConfigChanges((event) => {
|
|
|
|
|
if (event.source === CONFIG_EVENT_SOURCE) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (scopeMatches(event, "commands")) {
|
|
|
|
|
const { loadCommands } = useCommandsStore.getState();
|
|
|
|
|
void loadCommands();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|