Skip to content
Merged
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
143 changes: 80 additions & 63 deletions src/ai/registerAiCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {

import { AI_COMMAND_IDS, AI_LEGACY_COMMAND_IDS } from './aiCommandIds';

interface RegisterAiCommandsOptions {
export interface RegisterAiCommandsOptions {
plugin: Plugin;
getI18n: () => PluginI18nService | null;
getAi: () => NpdeAiApi | undefined;
Expand Down Expand Up @@ -51,69 +51,67 @@ function resolvePathFromClipboard(
return resolved;
}

export function registerAiCommands(options: RegisterAiCommandsOptions): void {
function registerCapabilitiesCommand(options: RegisterAiCommandsOptions, id: string, name: string): void {
const { plugin, getI18n, getAi } = options;
plugin.addCommand({
id,
name,
callback: async () => {
const ai = requireAi(getAi, getI18n);
if (!ai) return;
const manifest = ai.listCapabilities();
await copyJsonToClipboard(manifest);
showI18nNotice(getI18n(), 'settings:ai.manifestCopied');
},
});
}

const registerCapabilities = (id: string, name: string) => {
plugin.addCommand({
id,
name,
callback: async () => {
const ai = requireAi(getAi, getI18n);
if (!ai) return;
const manifest = ai.listCapabilities();
await copyJsonToClipboard(manifest);
showI18nNotice(getI18n(), 'settings:ai.manifestCopied');
},
});
};

const registerDescribe = (id: string, name: string) => {
plugin.addCommand({
id,
name,
callback: async () => {
const ai = requireAi(getAi, getI18n);
if (!ai) return;
try {
const payload = parseDescribeRequest(await readClipboardJson());
const path = resolvePathFromClipboard(plugin, getI18n, payload.path);
if (!path) return;
const result = await ai.describe(path);
await copyJsonToClipboard(result);
showI18nNotice(getI18n(), 'settings:ai.describeCopied');
} catch {
showI18nNotice(getI18n(), 'settings:ai.clipboardInvalid');
}
},
});
};

const registerApply = (id: string, name: string) => {
plugin.addCommand({
id,
name,
callback: async () => {
const ai = requireAi(getAi, getI18n);
if (!ai) return;
try {
const payload = parseApplyRequest(await readClipboardJson());
const path = resolvePathFromClipboard(plugin, getI18n, payload.path);
if (!path || !payload.ops) return;
const result = await ai.apply(path, payload.ops, { dryRun: payload.dryRun === true });
await copyJsonToClipboard(result);
showI18nNotice(getI18n(), 'settings:ai.applyCopied');
} catch {
showI18nNotice(getI18n(), 'settings:ai.clipboardInvalid');
}
},
});
};
function registerDescribeCommand(options: RegisterAiCommandsOptions, id: string, name: string): void {
const { plugin, getI18n, getAi } = options;
plugin.addCommand({
id,
name,
callback: async () => {
const ai = requireAi(getAi, getI18n);
if (!ai) return;
try {
const payload = parseDescribeRequest(await readClipboardJson());
const path = resolvePathFromClipboard(plugin, getI18n, payload.path);
if (!path) return;
const result = await ai.describe(path);
await copyJsonToClipboard(result);
showI18nNotice(getI18n(), 'settings:ai.describeCopied');
} catch {
showI18nNotice(getI18n(), 'settings:ai.clipboardInvalid');
}
},
});
}

registerCapabilities(AI_COMMAND_IDS.capabilities, 'AI: Copy capabilities (JSON)');
registerDescribe(AI_COMMAND_IDS.describe, 'AI: Describe document (clipboard JSON in/out)');
registerApply(AI_COMMAND_IDS.apply, 'AI: Apply operations (clipboard JSON in/out)');
function registerApplyCommand(options: RegisterAiCommandsOptions, id: string, name: string): void {
const { plugin, getI18n, getAi } = options;
plugin.addCommand({
id,
name,
callback: async () => {
const ai = requireAi(getAi, getI18n);
if (!ai) return;
try {
const payload = parseApplyRequest(await readClipboardJson());
const path = resolvePathFromClipboard(plugin, getI18n, payload.path);
if (!path || !payload.ops) return;
const result = await ai.apply(path, payload.ops, { dryRun: payload.dryRun === true });
await copyJsonToClipboard(result);
showI18nNotice(getI18n(), 'settings:ai.applyCopied');
} catch {
showI18nNotice(getI18n(), 'settings:ai.clipboardInvalid');
}
},
});
}

function registerValidateCommand(options: RegisterAiCommandsOptions): void {
const { plugin, getI18n, getAi } = options;
plugin.addCommand({
id: AI_COMMAND_IDS.validate,
name: 'AI: Validate operations (clipboard JSON in/out)',
Expand All @@ -130,7 +128,10 @@ export function registerAiCommands(options: RegisterAiCommandsOptions): void {
}
},
});
}

function registerSaveCommand(options: RegisterAiCommandsOptions): void {
const { plugin, getI18n, getAi } = options;
plugin.addCommand({
id: AI_COMMAND_IDS.save,
name: 'AI: Save document (clipboard JSON in/out)',
Expand All @@ -151,7 +152,10 @@ export function registerAiCommands(options: RegisterAiCommandsOptions): void {
}
},
});
}

function registerUndoCommand(options: RegisterAiCommandsOptions): void {
const { plugin, getI18n, getAi } = options;
plugin.addCommand({
id: AI_COMMAND_IDS.undo,
name: 'AI: Undo agent edit (clipboard JSON in/out)',
Expand All @@ -170,7 +174,10 @@ export function registerAiCommands(options: RegisterAiCommandsOptions): void {
}
},
});
}

function registerRedoCommand(options: RegisterAiCommandsOptions): void {
const { plugin, getI18n, getAi } = options;
plugin.addCommand({
id: AI_COMMAND_IDS.redo,
name: 'AI: Redo agent edit (clipboard JSON in/out)',
Expand All @@ -189,8 +196,18 @@ export function registerAiCommands(options: RegisterAiCommandsOptions): void {
}
},
});
}

export function registerAiCommands(options: RegisterAiCommandsOptions): void {
registerCapabilitiesCommand(options, AI_COMMAND_IDS.capabilities, 'AI: Copy capabilities (JSON)');
registerDescribeCommand(options, AI_COMMAND_IDS.describe, 'AI: Describe document (clipboard JSON in/out)');
registerApplyCommand(options, AI_COMMAND_IDS.apply, 'AI: Apply operations (clipboard JSON in/out)');
registerValidateCommand(options);
registerSaveCommand(options);
registerUndoCommand(options);
registerRedoCommand(options);

registerCapabilities(AI_LEGACY_COMMAND_IDS.capabilities, 'AI: Copy capability manifest (JSON, legacy id)');
registerDescribe(AI_LEGACY_COMMAND_IDS.describe, 'AI: Describe document from clipboard JSON (legacy id)');
registerApply(AI_LEGACY_COMMAND_IDS.apply, 'AI: Apply operations from clipboard JSON (legacy id)');
registerCapabilitiesCommand(options, AI_LEGACY_COMMAND_IDS.capabilities, 'AI: Copy capability manifest (JSON, legacy id)');
registerDescribeCommand(options, AI_LEGACY_COMMAND_IDS.describe, 'AI: Describe document from clipboard JSON (legacy id)');
registerApplyCommand(options, AI_LEGACY_COMMAND_IDS.apply, 'AI: Apply operations from clipboard JSON (legacy id)');
}
85 changes: 85 additions & 0 deletions tests/ai-register-commands.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { build } from 'esbuild';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';

const projectRoot = path.resolve(import.meta.dirname, '..');
const require = createRequire(import.meta.url);

let cachedModule;

const stubObsidianPlugin = {
name: 'stub-obsidian',
setup(buildContext) {
buildContext.onResolve({ filter: /^obsidian$/ }, () => ({ path: 'obsidian', namespace: 'stub-obsidian' }));
buildContext.onLoad({ filter: /.*/, namespace: 'stub-obsidian' }, () => ({
contents: 'module.exports = { Notice: class Notice {} };',
loader: 'js',
}));
},
};

async function loadRegisterAiCommandsModule() {
if (cachedModule) return cachedModule;
const outputDirectory = await mkdtemp(path.join(tmpdir(), 'npde-ai-reg-test-'));
const outfile = path.join(outputDirectory, 'registerAiCommands.cjs');
await build({
entryPoints: [path.join(projectRoot, 'src/ai/registerAiCommands.ts')],
bundle: true,
format: 'cjs',
logLevel: 'silent',
outfile,
platform: 'node',
target: 'node22',
plugins: [stubObsidianPlugin],
});
cachedModule = require(outfile);
return cachedModule;
}

test('registerAiCommands registers all standard and legacy commands', async () => {
const { registerAiCommands } = await loadRegisterAiCommandsModule();

const registeredCommands = [];
const mockPlugin = {
addCommand: (cmd) => {
registeredCommands.push(cmd);
},
app: {
workspace: {
getActiveFile: () => null,
},
},
};

const mockGetI18n = () => null;
const mockGetAi = () => undefined;

registerAiCommands({
plugin: mockPlugin,
getI18n: mockGetI18n,
getAi: mockGetAi,
});

assert.equal(registeredCommands.length, 10);

const commandIds = registeredCommands.map((c) => c.id);
assert.ok(commandIds.includes('npde-ai-capabilities'));
assert.ok(commandIds.includes('npde-ai-describe'));
assert.ok(commandIds.includes('npde-ai-apply'));
assert.ok(commandIds.includes('npde-ai-validate'));
assert.ok(commandIds.includes('npde-ai-save'));
assert.ok(commandIds.includes('npde-ai-undo'));
assert.ok(commandIds.includes('npde-ai-redo'));

assert.ok(commandIds.includes('npde-ai-list-capabilities'));
assert.ok(commandIds.includes('npde-ai-describe-document'));
assert.ok(commandIds.includes('npde-ai-apply-operations'));

// Test callback execution when AI is disabled
const capCmd = registeredCommands.find((c) => c.id === 'npde-ai-capabilities');
await capCmd.callback();
});