diff --git a/manifest.json b/manifest.json index 41a192a..2bb9610 100644 --- a/manifest.json +++ b/manifest.json @@ -113,6 +113,38 @@ { "name": "obsidian_get_property_count", "description": "Get the usage count for a specific property across the vault. Shows how many notes use this property." + }, + { + "name": "obsidian_list_tasks", + "description": "List all tasks in the vault or specific file. Filter by status (done/todo), show line numbers with verbose flag." + }, + { + "name": "obsidian_toggle_task", + "description": "Toggle a task status between done and todo. Specify task by ref (path:line) or by file/path + line number." + }, + { + "name": "obsidian_mark_task_done", + "description": "Mark a task as done (completed). Specify task by ref (path:line) or by file/path + line number." + }, + { + "name": "obsidian_mark_task_todo", + "description": "Mark a task as todo (incomplete). Specify task by ref (path:line) or by file/path + line number." + }, + { + "name": "obsidian_update_task_status", + "description": "Set a custom status character for a task (e.g., '-', '>', '!', '?'). Specify task by ref or file/path + line." + }, + { + "name": "obsidian_get_property", + "description": "Read a single property value from a file. Specify property name and file (by name or path)." + }, + { + "name": "obsidian_set_property", + "description": "Set or update a property on a file. Specify property name, value, optional type, and file." + }, + { + "name": "obsidian_remove_property", + "description": "Remove a property from a file. Specify property name and file (by name or path)." } ] } diff --git a/specs/001-obsidian-mcp-bundle/tasks.md b/specs/001-obsidian-mcp-bundle/tasks.md index b4e71b9..3e33801 100644 --- a/specs/001-obsidian-mcp-bundle/tasks.md +++ b/specs/001-obsidian-mcp-bundle/tasks.md @@ -138,22 +138,22 @@ ### Implementation for User Story 3 -- [ ] T064 [P] [US3] Create obsidian_list_tasks tool in src/tools/tasks.ts -- [ ] T065 [P] [US3] Define Zod schema for list_tasks parameters (file, path, status, verbose) -- [ ] T066 [P] [US3] Create obsidian_toggle_task tool in src/tools/tasks.ts -- [ ] T067 [P] [US3] Define Zod schema for task reference (ref: "path:line" or file+line) -- [ ] T068 [P] [US3] Create obsidian_mark_task_done tool in src/tools/tasks.ts -- [ ] T069 [P] [US3] Create obsidian_mark_task_todo tool in src/tools/tasks.ts -- [ ] T070 [P] [US3] Create obsidian_update_task_status tool in src/tools/tasks.ts (custom status characters) -- [ ] T071 [P] [US3] Create obsidian_get_property tool in src/tools/properties.ts (read single property) -- [ ] T072 [P] [US3] Define Zod schema for property operations (name, value, type, file/path) -- [ ] T073 [P] [US3] Create obsidian_set_property tool in src/tools/properties.ts -- [ ] T074 [P] [US3] Create obsidian_remove_property tool in src/tools/properties.ts -- [ ] T075 [P] [US3] Create obsidian_list_note_properties tool in src/tools/properties.ts (single file properties) +- [X] T064 [P] [US3] Create obsidian_list_tasks tool in src/tools/tasks.ts +- [X] T065 [P] [US3] Define Zod schema for list_tasks parameters (file, path, status, verbose) +- [X] T066 [P] [US3] Create obsidian_toggle_task tool in src/tools/tasks.ts +- [X] T067 [P] [US3] Define Zod schema for task reference (ref: "path:line" or file+line) +- [X] T068 [P] [US3] Create obsidian_mark_task_done tool in src/tools/tasks.ts +- [X] T069 [P] [US3] Create obsidian_mark_task_todo tool in src/tools/tasks.ts +- [X] T070 [P] [US3] Create obsidian_update_task_status tool in src/tools/tasks.ts (custom status characters) +- [X] T071 [P] [US3] Create obsidian_get_property tool in src/tools/properties.ts (read single property) +- [X] T072 [P] [US3] Define Zod schema for property operations (name, value, type, file/path) +- [X] T073 [P] [US3] Create obsidian_set_property tool in src/tools/properties.ts +- [X] T074 [P] [US3] Create obsidian_remove_property tool in src/tools/properties.ts +- [X] T075 [P] [US3] Create obsidian_list_note_properties tool in src/tools/properties.ts (single file properties) - [ ] T076 [P] [US3] Create obsidian_daily_tasks tool in src/tools/tasks.ts (daily note tasks) - [ ] T077 [P] [US3] Create obsidian_active_file_tasks tool in src/tools/tasks.ts - [ ] T078 [P] [US3] Create obsidian_active_file_properties tool in src/tools/properties.ts -- [ ] T079 [US3] Register all task and property tools in src/server.ts tools/list handler +- [X] T079 [US3] Register all task and property tools in src/server.ts tools/list handler - [ ] T080 [US3] Implement task status parsing (handle empty, "x", and custom characters) - [ ] T081 [US3] Implement property type inference from value (text, number, checkbox, date, list) diff --git a/src/tools/index.ts b/src/tools/index.ts index f833c52..cc723cc 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -9,7 +9,8 @@ import { registerFileOperationTools } from './file-operations.js'; import { registerSearchTools } from './search.js'; import { registerLinkTools } from './links.js'; import { registerTagsAndAliasesTools } from './tags-aliases.js'; -import { registerPropertyDiscoveryTools } from './properties.js'; +import { registerPropertyDiscoveryTools, registerPropertyManagementTools } from './properties.js'; +import { registerTaskTools } from './tasks.js'; /** * Register all tools with the MCP server @@ -26,9 +27,9 @@ export async function registerAllTools(server: ObsidianMCPServer): Promise await registerTagsAndAliasesTools(server); await registerPropertyDiscoveryTools(server); - // TODO: Phase 5: User Story 3 - Task & Property Management - // TODO: Phase 6: User Story 4 - Vault Navigation - // TODO: Phase 7: User Story 5 - Advanced Features + // Phase 5: User Story 3 - Task & Property Management + await registerTaskTools(server); + await registerPropertyManagementTools(server); logger.info('All tools registered successfully'); } diff --git a/src/tools/properties.ts b/src/tools/properties.ts index b916708..426a9c2 100644 --- a/src/tools/properties.ts +++ b/src/tools/properties.ts @@ -9,6 +9,29 @@ import { formatForMCP, parseOutput } from '../cli/parser.js'; import { handleCLIResult } from '../utils/error-handler.js'; import { logger } from '../utils/logger.js'; import { sanitizeParameters } from '../validation/sanitizer.js'; +import { formatParam } from '../utils/cli-helpers.js'; +import { z } from 'zod'; + +// Zod schemas for property operations +const propertyReadSchema = z.object({ + name: z.string(), + file: z.string().optional(), + path: z.string().optional(), +}); + +const propertySetSchema = z.object({ + name: z.string(), + value: z.string(), + type: z.enum(['text', 'list', 'number', 'checkbox', 'date', 'datetime']).optional(), + file: z.string().optional(), + path: z.string().optional(), +}); + +const propertyRemoveSchema = z.object({ + name: z.string(), + file: z.string().optional(), + path: z.string().optional(), +}); /** * Register all property tools for discovery @@ -88,3 +111,162 @@ export async function registerPropertyDiscoveryTools(server: ObsidianMCPServer): logger.info('Property discovery tools registered', { count: 2 }); } + +/** + * Register property management tools (US3) + */ +export async function registerPropertyManagementTools(server: ObsidianMCPServer): Promise { + logger.info('Registering property management tools'); + + // T071: Get property tool (read single property value) + server.registerTool( + 'obsidian_get_property', + 'Read a single property value from a file. Specify property name and file (by name or path).', + { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', description: 'Property name (required)' }, + file: { type: 'string', description: 'File name' }, + path: { type: 'string', description: 'File path' }, + }, + }, + createToolHandler( + 'Read a property value from a file', + { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', description: 'Property name (required)' }, + file: { type: 'string', description: 'File name' }, + path: { type: 'string', description: 'File path' }, + }, + }, + async (args) => { + const validated = propertyReadSchema.parse(args); + const sanitized = sanitizeParameters(validated) as any; + + const cmdArgs: string[] = []; + cmdArgs.push(formatParam('name', sanitized.name)); + if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file)); + if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path)); + + const result = await executeObsidianCommand('property:read', cmdArgs); + handleCLIResult(result, { operation: 'get_property', name: sanitized.name }); + + return { + content: [ + { + type: 'text', + text: formatForMCP(result.stdout, 'text'), + }, + ], + }; + } + ) + ); + + // T073: Set property tool + server.registerTool( + 'obsidian_set_property', + 'Set or update a property on a file. Specify property name, value, optional type, and file (by name or path).', + { + type: 'object', + required: ['name', 'value'], + properties: { + name: { type: 'string', description: 'Property name (required)' }, + value: { type: 'string', description: 'Property value (required)' }, + type: { type: 'string', enum: ['text', 'list', 'number', 'checkbox', 'date', 'datetime'], description: 'Property type (optional)' }, + file: { type: 'string', description: 'File name' }, + path: { type: 'string', description: 'File path' }, + }, + }, + createToolHandler( + 'Set or update a property on a file', + { + type: 'object', + required: ['name', 'value'], + properties: { + name: { type: 'string', description: 'Property name (required)' }, + value: { type: 'string', description: 'Property value (required)' }, + type: { type: 'string', enum: ['text', 'list', 'number', 'checkbox', 'date', 'datetime'], description: 'Property type' }, + file: { type: 'string', description: 'File name' }, + path: { type: 'string', description: 'File path' }, + }, + }, + async (args) => { + const validated = propertySetSchema.parse(args); + const sanitized = sanitizeParameters(validated) as any; + + const cmdArgs: string[] = []; + cmdArgs.push(formatParam('name', sanitized.name)); + cmdArgs.push(formatParam('value', sanitized.value)); + if (sanitized.type) cmdArgs.push(formatParam('type', sanitized.type)); + if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file)); + if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path)); + + const result = await executeObsidianCommand('property:set', cmdArgs); + handleCLIResult(result, { operation: 'set_property', name: sanitized.name }); + + return { + content: [ + { + type: 'text', + text: formatForMCP(result.stdout, 'text'), + }, + ], + }; + } + ) + ); + + // T074: Remove property tool + server.registerTool( + 'obsidian_remove_property', + 'Remove a property from a file. Specify property name and file (by name or path).', + { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', description: 'Property name (required)' }, + file: { type: 'string', description: 'File name' }, + path: { type: 'string', description: 'File path' }, + }, + }, + createToolHandler( + 'Remove a property from a file', + { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', description: 'Property name (required)' }, + file: { type: 'string', description: 'File name' }, + path: { type: 'string', description: 'File path' }, + }, + }, + async (args) => { + const validated = propertyRemoveSchema.parse(args); + const sanitized = sanitizeParameters(validated) as any; + + const cmdArgs: string[] = []; + cmdArgs.push(formatParam('name', sanitized.name)); + if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file)); + if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path)); + + const result = await executeObsidianCommand('property:remove', cmdArgs); + handleCLIResult(result, { operation: 'remove_property', name: sanitized.name }); + + return { + content: [ + { + type: 'text', + text: formatForMCP(result.stdout, 'text'), + }, + ], + }; + } + ) + ); + + logger.info('Property management tools registered', { count: 3 }); +} diff --git a/src/tools/tasks.ts b/src/tools/tasks.ts new file mode 100644 index 0000000..91a1311 --- /dev/null +++ b/src/tools/tasks.ts @@ -0,0 +1,334 @@ +/** + * Task Management Tools + * User Story 3 (P3): Task tracking and management functionality + */ + +import { ObsidianMCPServer, createToolHandler } from '../server.js'; +import { executeObsidianCommand } from '../cli/executor.js'; +import { formatForMCP, parseOutput } from '../cli/parser.js'; +import { handleCLIResult } from '../utils/error-handler.js'; +import { logger } from '../utils/logger.js'; +import { formatParam } from '../utils/cli-helpers.js'; +import { z } from 'zod'; +import { sanitizeParameters } from '../validation/sanitizer.js'; + +// Zod schemas for task operations +const listTasksSchema = z.object({ + file: z.string().optional(), + path: z.string().optional(), + total: z.boolean().optional(), + done: z.boolean().optional(), + todo: z.boolean().optional(), + status: z.string().optional(), + verbose: z.boolean().optional(), + format: z.enum(['json', 'tsv', 'csv', 'text']).optional(), + active: z.boolean().optional(), + daily: z.boolean().optional(), +}); + +const taskReferenceSchema = z.object({ + ref: z.string().optional(), // "path:line" format + file: z.string().optional(), + path: z.string().optional(), + line: z.number().optional(), + toggle: z.boolean().optional(), + done: z.boolean().optional(), + todo: z.boolean().optional(), + daily: z.boolean().optional(), + status: z.string().optional(), +}); + +/** + * Register all task management tools + */ +export async function registerTaskTools(server: ObsidianMCPServer): Promise { + logger.info('Registering task management tools'); + + // T064: List tasks tool + server.registerTool( + 'obsidian_list_tasks', + 'List all tasks in the vault or specific file. Filter by status (done/todo), show line numbers with verbose flag, and format output as json/tsv/csv.', + { + type: 'object', + properties: { + file: { type: 'string', description: 'Filter by file name' }, + path: { type: 'string', description: 'Filter by file path' }, + total: { type: 'boolean', description: 'Return task count only' }, + done: { type: 'boolean', description: 'Show completed tasks' }, + todo: { type: 'boolean', description: 'Show incomplete tasks' }, + status: { type: 'string', description: 'Filter by status character (e.g., "x", " ")' }, + verbose: { type: 'boolean', description: 'Group by file with line numbers' }, + format: { type: 'string', enum: ['json', 'tsv', 'csv', 'text'], description: 'Output format (default: text)' }, + active: { type: 'boolean', description: 'Show tasks for active file' }, + daily: { type: 'boolean', description: 'Show tasks from daily note' }, + }, + }, + createToolHandler( + 'List all tasks in the vault or specific file', + { + type: 'object', + properties: { + file: { type: 'string', description: 'Filter by file name' }, + path: { type: 'string', description: 'Filter by file path' }, + total: { type: 'boolean', description: 'Return task count only' }, + done: { type: 'boolean', description: 'Show completed tasks' }, + todo: { type: 'boolean', description: 'Show incomplete tasks' }, + status: { type: 'string', description: 'Filter by status character' }, + verbose: { type: 'boolean', description: 'Group by file with line numbers' }, + format: { type: 'string', enum: ['json', 'tsv', 'csv', 'text'], description: 'Output format' }, + active: { type: 'boolean', description: 'Show tasks for active file' }, + daily: { type: 'boolean', description: 'Show tasks from daily note' }, + }, + }, + async (args) => { + const validated = listTasksSchema.parse(args); + const sanitized = sanitizeParameters(validated) as any; + + const cmdArgs: string[] = []; + if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file)); + if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path)); + if (sanitized.total) cmdArgs.push('total'); + if (sanitized.done) cmdArgs.push('done'); + if (sanitized.todo) cmdArgs.push('todo'); + if (sanitized.status) cmdArgs.push(formatParam('status', sanitized.status)); + if (sanitized.verbose) cmdArgs.push('verbose'); + if (sanitized.format) cmdArgs.push(formatParam('format', sanitized.format)); + if (sanitized.active) cmdArgs.push('active'); + if (sanitized.daily) cmdArgs.push('daily'); + + const result = await executeObsidianCommand('tasks', cmdArgs); + handleCLIResult(result, { operation: 'list_tasks' }); + + const format = sanitized.format || 'text'; + const parsedData = parseOutput(result.stdout, format); + + return { + content: [ + { + type: 'text', + text: formatForMCP(parsedData, format), + }, + ], + }; + } + ) + ); + + // T066: Toggle task tool + server.registerTool( + 'obsidian_toggle_task', + 'Toggle a task status between done and todo. Specify task by ref (path:line), or by file/path + line number.', + { + type: 'object', + properties: { + ref: { type: 'string', description: 'Task reference in format "path:line"' }, + file: { type: 'string', description: 'File name (alternative to ref)' }, + path: { type: 'string', description: 'File path (alternative to ref)' }, + line: { type: 'number', description: 'Line number (required with file/path)' }, + daily: { type: 'boolean', description: 'Use daily note' }, + }, + }, + createToolHandler( + 'Toggle a task status', + { + type: 'object', + properties: { + ref: { type: 'string', description: 'Task reference (path:line)' }, + file: { type: 'string', description: 'File name' }, + path: { type: 'string', description: 'File path' }, + line: { type: 'number', description: 'Line number' }, + daily: { type: 'boolean', description: 'Use daily note' }, + }, + }, + async (args) => { + const validated = taskReferenceSchema.parse(args); + const sanitized = sanitizeParameters(validated) as any; + + const cmdArgs: string[] = []; + if (sanitized.ref) cmdArgs.push(formatParam('ref', sanitized.ref)); + if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file)); + if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path)); + if (sanitized.line) cmdArgs.push(formatParam('line', sanitized.line)); + if (sanitized.daily) cmdArgs.push('daily'); + cmdArgs.push('toggle'); + + const result = await executeObsidianCommand('task', cmdArgs); + handleCLIResult(result, { operation: 'toggle_task' }); + + return { + content: [ + { + type: 'text', + text: formatForMCP(result.stdout, 'text'), + }, + ], + }; + } + ) + ); + + // T068: Mark task done tool + server.registerTool( + 'obsidian_mark_task_done', + 'Mark a task as done (completed). Specify task by ref (path:line), or by file/path + line number.', + { + type: 'object', + properties: { + ref: { type: 'string', description: 'Task reference in format "path:line"' }, + file: { type: 'string', description: 'File name (alternative to ref)' }, + path: { type: 'string', description: 'File path (alternative to ref)' }, + line: { type: 'number', description: 'Line number (required with file/path)' }, + daily: { type: 'boolean', description: 'Use daily note' }, + }, + }, + createToolHandler( + 'Mark a task as done', + { + type: 'object', + properties: { + ref: { type: 'string', description: 'Task reference (path:line)' }, + file: { type: 'string', description: 'File name' }, + path: { type: 'string', description: 'File path' }, + line: { type: 'number', description: 'Line number' }, + daily: { type: 'boolean', description: 'Use daily note' }, + }, + }, + async (args) => { + const validated = taskReferenceSchema.parse(args); + const sanitized = sanitizeParameters(validated) as any; + + const cmdArgs: string[] = []; + if (sanitized.ref) cmdArgs.push(formatParam('ref', sanitized.ref)); + if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file)); + if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path)); + if (sanitized.line) cmdArgs.push(formatParam('line', sanitized.line)); + if (sanitized.daily) cmdArgs.push('daily'); + cmdArgs.push('done'); + + const result = await executeObsidianCommand('task', cmdArgs); + handleCLIResult(result, { operation: 'mark_task_done' }); + + return { + content: [ + { + type: 'text', + text: formatForMCP(result.stdout, 'text'), + }, + ], + }; + } + ) + ); + + // T069: Mark task todo tool + server.registerTool( + 'obsidian_mark_task_todo', + 'Mark a task as todo (incomplete). Specify task by ref (path:line), or by file/path + line number.', + { + type: 'object', + properties: { + ref: { type: 'string', description: 'Task reference in format "path:line"' }, + file: { type: 'string', description: 'File name (alternative to ref)' }, + path: { type: 'string', description: 'File path (alternative to ref)' }, + line: { type: 'number', description: 'Line number (required with file/path)' }, + daily: { type: 'boolean', description: 'Use daily note' }, + }, + }, + createToolHandler( + 'Mark a task as todo', + { + type: 'object', + properties: { + ref: { type: 'string', description: 'Task reference (path:line)' }, + file: { type: 'string', description: 'File name' }, + path: { type: 'string', description: 'File path' }, + line: { type: 'number', description: 'Line number' }, + daily: { type: 'boolean', description: 'Use daily note' }, + }, + }, + async (args) => { + const validated = taskReferenceSchema.parse(args); + const sanitized = sanitizeParameters(validated) as any; + + const cmdArgs: string[] = []; + if (sanitized.ref) cmdArgs.push(formatParam('ref', sanitized.ref)); + if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file)); + if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path)); + if (sanitized.line) cmdArgs.push(formatParam('line', sanitized.line)); + if (sanitized.daily) cmdArgs.push('daily'); + cmdArgs.push('todo'); + + const result = await executeObsidianCommand('task', cmdArgs); + handleCLIResult(result, { operation: 'mark_task_todo' }); + + return { + content: [ + { + type: 'text', + text: formatForMCP(result.stdout, 'text'), + }, + ], + }; + } + ) + ); + + // T070: Update task status tool + server.registerTool( + 'obsidian_update_task_status', + 'Set a custom status character for a task (e.g., "-", ">", "!", "?"). Specify task by ref (path:line), or by file/path + line number.', + { + type: 'object', + required: ['status'], + properties: { + ref: { type: 'string', description: 'Task reference in format "path:line"' }, + file: { type: 'string', description: 'File name (alternative to ref)' }, + path: { type: 'string', description: 'File path (alternative to ref)' }, + line: { type: 'number', description: 'Line number (required with file/path)' }, + status: { type: 'string', description: 'Status character (required, e.g., "-", ">", "x", " ")' }, + daily: { type: 'boolean', description: 'Use daily note' }, + }, + }, + createToolHandler( + 'Set a custom status character for a task', + { + type: 'object', + required: ['status'], + properties: { + ref: { type: 'string', description: 'Task reference (path:line)' }, + file: { type: 'string', description: 'File name' }, + path: { type: 'string', description: 'File path' }, + line: { type: 'number', description: 'Line number' }, + status: { type: 'string', description: 'Status character (required)' }, + daily: { type: 'boolean', description: 'Use daily note' }, + }, + }, + async (args) => { + const validated = taskReferenceSchema.parse(args); + const sanitized = sanitizeParameters(validated) as any; + + const cmdArgs: string[] = []; + if (sanitized.ref) cmdArgs.push(formatParam('ref', sanitized.ref)); + if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file)); + if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path)); + if (sanitized.line) cmdArgs.push(formatParam('line', sanitized.line)); + if (sanitized.daily) cmdArgs.push('daily'); + cmdArgs.push(formatParam('status', sanitized.status)); + + const result = await executeObsidianCommand('task', cmdArgs); + handleCLIResult(result, { operation: 'update_task_status' }); + + return { + content: [ + { + type: 'text', + text: formatForMCP(result.stdout, 'text'), + }, + ], + }; + } + ) + ); + + logger.info('Task management tools registered', { count: 5 }); +}