feat: add command scope support and reload functionality

- Introduced command scope types (user and project) to manage command configurations at different levels.
- Enhanced command management functions to check and create commands based on their scope.
- Updated the API to handle command creation, updates, and deletions with respect to their scope.
- Added a reload button in the AboutSettings component to refresh OpenCode configuration.
- Improved command source retrieval to prioritize project-level commands over user-level commands.
- Refactored related functions to ensure proper handling of command paths and configurations based on scope.
This commit is contained in:
Bohdan Triapitsyn
2025-12-29 17:48:06 +02:00
parent 66351bea6c
commit 0adcba854b
12 changed files with 1008 additions and 268 deletions
+67 -4
View File
@@ -10,6 +10,9 @@ import {
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import { getSafeStorage } from "./utils/safeStorage";
import { useConfigStore } from "@/stores/useConfigStore";
import { useDirectoryStore } from "@/stores/useDirectoryStore";
export type CommandScope = 'user' | 'project';
export interface CommandConfig {
name: string;
@@ -18,12 +21,20 @@ export interface CommandConfig {
model?: string | null;
template?: string;
subtask?: boolean;
scope?: CommandScope;
}
export interface Command extends CommandConfig {
isBuiltIn?: boolean;
}
// 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);
};
const CONFIG_EVENT_SOURCE = "useCommandsStore";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const MAX_HEALTH_WAIT_MS = 20000;
@@ -33,13 +44,25 @@ const SLOW_HEALTH_POLL_BASE_MS = 800;
const SLOW_HEALTH_POLL_INCREMENT_MS = 200;
const SLOW_HEALTH_POLL_MAX_MS = 2000;
export interface CommandDraft {
name: string;
scope: CommandScope;
description?: string;
agent?: string | null;
model?: string | null;
template?: string;
subtask?: boolean;
}
interface CommandsStore {
selectedCommandName: string | null;
commands: Command[];
isLoading: boolean;
commandDraft: CommandDraft | null;
setSelectedCommand: (name: string | null) => void;
setCommandDraft: (draft: CommandDraft | null) => void;
loadCommands: () => Promise<boolean>;
createCommand: (config: CommandConfig) => Promise<boolean>;
updateCommand: (name: string, config: Partial<CommandConfig>) => Promise<boolean>;
@@ -61,11 +84,16 @@ export const useCommandsStore = create<CommandsStore>()(
selectedCommandName: null,
commands: [],
isLoading: false,
commandDraft: null,
setSelectedCommand: (name: string | null) => {
set({ selectedCommandName: name });
},
setCommandDraft: (draft: CommandDraft | null) => {
set({ commandDraft: draft });
},
loadCommands: async () => {
set({ isLoading: true });
const previousCommands = get().commands;
@@ -74,7 +102,29 @@ export const useCommandsStore = create<CommandsStore>()(
for (let attempt = 0; attempt < 3; attempt++) {
try {
const commands = await opencodeClient.listCommandsWithDetails();
set({ commands, isLoading: false });
// Fetch scope info for each command
const currentDirectory = useDirectoryStore.getState().currentDirectory;
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const commandsWithScope = await Promise.all(
commands.map(async (cmd) => {
try {
const response = await fetch(`/api/config/commands/${encodeURIComponent(cmd.name)}${queryParams}`);
if (response.ok) {
const data = await response.json();
// Handle both web (data.scope) and desktop (data.sources.md.scope) response formats
const scope = data.scope ?? data.sources?.md?.scope;
return { ...cmd, scope: scope as CommandScope | undefined };
}
} catch {
// Ignore errors fetching scope
}
return cmd;
})
);
set({ commands: commandsWithScope, isLoading: false });
return true;
} catch (error) {
lastError = error;
@@ -102,10 +152,15 @@ export const useCommandsStore = create<CommandsStore>()(
if (config.agent) commandConfig.agent = config.agent;
if (config.model) commandConfig.model = config.model;
if (config.subtask !== undefined) commandConfig.subtask = config.subtask;
if (config.scope) commandConfig.scope = config.scope;
console.log('[CommandsStore] Command config to save:', commandConfig);
const response = await fetch(`/api/config/commands/${encodeURIComponent(config.name)}`, {
// Get current directory for project-level command support
const currentDirectory = useDirectoryStore.getState().currentDirectory;
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/commands/${encodeURIComponent(config.name)}${queryParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(commandConfig)
@@ -161,7 +216,11 @@ export const useCommandsStore = create<CommandsStore>()(
console.log('[CommandsStore] Command config to update:', commandConfig);
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}`, {
// Get current directory for project-level command support
const currentDirectory = useDirectoryStore.getState().currentDirectory;
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(commandConfig)
@@ -204,7 +263,11 @@ export const useCommandsStore = create<CommandsStore>()(
startConfigUpdate("Deleting command configuration…");
let requiresReload = false;
try {
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}`, {
// Get current directory for project-level command support
const currentDirectory = useDirectoryStore.getState().currentDirectory;
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/commands/${encodeURIComponent(name)}${queryParams}`, {
method: 'DELETE'
});