Преглед изворни кода

feat: initial release of zoho-tasks plugin (v1.0.0)

Zoho Projects task management integration for EU data center.
Features: OAuth2 with auto-refresh, CRUD tasks, project listing, search.

Tools: setup_auth, auth_status, list_projects, list_tasks, get_task,
create_task, update_task, delete_task, my_tasks, search_tasks.
ShadowMan Service User пре 2 дана
комит
6a84b1b596
6 измењених фајлова са 685 додато и 0 уклоњено
  1. 4 0
      .gitignore
  2. 3 0
      .spkgignore
  3. 76 0
      index.js
  4. 179 0
      lib/auth.js
  5. 275 0
      lib/tasks.js
  6. 148 0
      manifest.json

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+logs/
+*.spkg
+node_modules/
+.env

+ 3 - 0
.spkgignore

@@ -0,0 +1,3 @@
+logs/
+*.spkg
+.git/

+ 76 - 0
index.js

@@ -0,0 +1,76 @@
+// Zoho Tasks Plugin v1.0.0
+// EU data center: accounts.zoho.eu / projectsapi.zoho.eu
+var auth = require('./lib/auth');
+var tasks = require('./lib/tasks');
+
+shadowman.log.info('Zoho Tasks plugin v1.0.0 loaded');
+
+// Auth tools
+shadowman.tools.register('setup_auth', function(args, context) {
+    shadowman.storage.set('auth_conversation_id', context.conversationId);
+    return auth.setupAuth(args.grant_token);
+});
+
+shadowman.tools.register('auth_status', function(args, context) {
+    return auth.getStatus();
+});
+
+// Project tools
+shadowman.tools.register('list_projects', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    return tasks.listProjects(token, args.index || 1);
+});
+
+// Task tools
+shadowman.tools.register('list_tasks', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    if (!args.project_id) return { error: 'Missing project_id parameter' };
+    return tasks.listTasks(token, args.project_id, args);
+});
+
+shadowman.tools.register('get_task', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    if (!args.project_id) return { error: 'Missing project_id parameter' };
+    if (!args.task_id) return { error: 'Missing task_id parameter' };
+    return tasks.getTask(token, args.project_id, args.task_id);
+});
+
+shadowman.tools.register('create_task', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    if (!args.project_id) return { error: 'Missing project_id parameter' };
+    if (!args.name) return { error: 'Missing name parameter' };
+    return tasks.createTask(token, args.project_id, args);
+});
+
+shadowman.tools.register('update_task', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    if (!args.project_id) return { error: 'Missing project_id parameter' };
+    if (!args.task_id) return { error: 'Missing task_id parameter' };
+    return tasks.updateTask(token, args.project_id, args.task_id, args);
+});
+
+shadowman.tools.register('delete_task', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    if (!args.project_id) return { error: 'Missing project_id parameter' };
+    if (!args.task_id) return { error: 'Missing task_id parameter' };
+    return tasks.deleteTask(token, args.project_id, args.task_id);
+});
+
+shadowman.tools.register('my_tasks', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    return tasks.getMyTasks(token, args);
+});
+
+shadowman.tools.register('search_tasks', function(args, context) {
+    var token = auth.getValidToken();
+    if (!token) return { error: 'Not authenticated. Run setup_auth with a grant token first.' };
+    if (!args.query) return { error: 'Missing query parameter' };
+    return tasks.searchTasks(token, args);
+});

+ 179 - 0
lib/auth.js

@@ -0,0 +1,179 @@
+// Zoho OAuth2 Authentication Module
+// EU data center: accounts.zoho.eu
+
+var ACCOUNTS_BASE = 'https://accounts.zoho.eu';
+var SCOPE = 'ZohoProjects.tasks.ALL,ZohoProjects.projects.READ,ZohoProjects.tasklists.READ';
+
+function setupAuth(grantToken) {
+    var clientId = shadowman.config.value('client_id');
+    var clientSecret = shadowman.config.value('client_secret');
+
+    if (!clientId || !clientSecret) {
+        return { error: 'Missing OAuth credentials. Set client_id and client_secret in plugin config.' };
+    }
+
+    if (!grantToken) {
+        return { error: 'Missing grant_token parameter. Generate one from Zoho Developer Console (Self Client).' };
+    }
+
+    shadowman.log.info('Exchanging Zoho grant token for access + refresh tokens...');
+
+    // Exchange grant token for access + refresh tokens
+    // POST /oauth/v2/token with grant_type=authorization_code
+    var body = 'grant_type=authorization_code' +
+               '&client_id=' + encodeURIComponent(clientId) +
+               '&client_secret=' + encodeURIComponent(clientSecret) +
+               '&redirect_uri=oob' +
+               '&code=' + encodeURIComponent(grantToken);
+
+    var resp = shadowman.http.post(ACCOUNTS_BASE + '/oauth/v2/token', body, {
+        headers: {
+            'Content-Type': 'application/x-www-form-urlencoded'
+        }
+    });
+
+    if (resp.status !== 200) {
+        shadowman.log.error('Token exchange failed: ' + resp.status + ' ' + resp.body);
+        var errBody;
+        try {
+            errBody = JSON.parse(resp.body);
+        } catch (e) {
+            errBody = resp.body;
+        }
+        return { error: 'Token exchange failed: ' + (errBody.error || errBody.error_description || resp.body) };
+    }
+
+    var tokens;
+    try {
+        tokens = JSON.parse(resp.body);
+    } catch (e) {
+        return { error: 'Invalid response from Zoho: ' + resp.body };
+    }
+
+    // Store tokens with expiry calculation
+    shadowman.storage.set('tokens', {
+        access_token: tokens.access_token,
+        refresh_token: tokens.refresh_token,
+        expires_at: Date.now() + (tokens.expires_in * 1000),
+        api_domain: tokens.api_domain || 'https://projectsapi.zoho.eu',
+        token_type: tokens.token_type || 'Bearer'
+    });
+
+    shadowman.log.info('Zoho OAuth tokens stored successfully. Refresh token does not expire.');
+
+    // Notify conversation
+    var convId = shadowman.storage.get('auth_conversation_id');
+    if (convId) {
+        shadowman.events.emit('message', {
+            text: 'Zoho OAuth sikeres! A refresh token nem jár le, az access token automatikusan frissül. Most már használhatod a Zoho Tasks funkciókat.',
+            conversationId: convId
+        });
+        shadowman.storage.del('auth_conversation_id');
+    }
+
+    return {
+        success: true,
+        message: 'Authentication successful. Refresh token stored permanently.',
+        expires_in: tokens.expires_in
+    };
+}
+
+function getStatus() {
+    var tokens = shadowman.storage.get('tokens');
+    if (!tokens) {
+        return { authenticated: false, message: 'Not authenticated. Use setup_auth with a grant token.' };
+    }
+
+    if (Date.now() >= tokens.expires_at) {
+        // Token expired, try refresh
+        var newTokens = refreshTokenInternal();
+        if (newTokens.error) {
+            return { authenticated: false, message: 'Token expired and refresh failed. Re-run setup_auth.' };
+        }
+        return { authenticated: true, message: 'Authenticated (token refreshed)', domain: newTokens.api_domain };
+    }
+
+    var remaining = Math.round((tokens.expires_at - Date.now()) / 1000);
+    return {
+        authenticated: true,
+        message: 'Authenticated',
+        expires_in_seconds: remaining,
+        domain: tokens.api_domain
+    };
+}
+
+function getValidToken() {
+    var tokens = shadowman.storage.get('tokens');
+    if (!tokens) {
+        return null;
+    }
+
+    // Check if token needs refresh (5 minute buffer)
+    if (Date.now() >= (tokens.expires_at - 300000)) {
+        var newTokens = refreshTokenInternal();
+        if (newTokens.error) {
+            shadowman.log.error('Token refresh failed: ' + newTokens.error);
+            return null;
+        }
+        return newTokens.access_token;
+    }
+
+    return tokens.access_token;
+}
+
+function refreshTokenInternal() {
+    var tokens = shadowman.storage.get('tokens');
+    if (!tokens || !tokens.refresh_token) {
+        return { error: 'No refresh token available' };
+    }
+
+    var clientId = shadowman.config.value('client_id');
+    var clientSecret = shadowman.config.value('client_secret');
+
+    if (!clientId || !clientSecret) {
+        return { error: 'OAuth client credentials missing' };
+    }
+
+    shadowman.log.info('Refreshing Zoho access token...');
+
+    var resp = shadowman.http.post(ACCOUNTS_BASE + '/oauth/v2/token', null, {
+        headers: {
+            'Content-Type': 'application/x-www-form-urlencoded'
+        },
+        body: 'grant_type=refresh_token' +
+              '&client_id=' + encodeURIComponent(clientId) +
+              '&client_secret=' + encodeURIComponent(clientSecret) +
+              '&refresh_token=' + encodeURIComponent(tokens.refresh_token)
+    });
+
+    if (resp.status !== 200) {
+        shadowman.log.error('Token refresh failed: ' + resp.status + ' ' + resp.body);
+        return { error: 'Refresh failed: ' + resp.body };
+    }
+
+    var newTokens;
+    try {
+        newTokens = JSON.parse(resp.body);
+    } catch (e) {
+        return { error: 'Invalid refresh response: ' + resp.body };
+    }
+
+    var updated = {
+        access_token: newTokens.access_token,
+        refresh_token: tokens.refresh_token, // Keep original - Zoho refresh tokens don't expire
+        expires_at: Date.now() + (newTokens.expires_in * 1000),
+        api_domain: newTokens.api_domain || tokens.api_domain,
+        token_type: newTokens.token_type || 'Bearer'
+    };
+
+    shadowman.storage.set('tokens', updated);
+    shadowman.log.info('Zoho token refreshed successfully');
+
+    return updated;
+}
+
+module.exports = {
+    setupAuth: setupAuth,
+    getStatus: getStatus,
+    getValidToken: getValidToken
+};

+ 275 - 0
lib/tasks.js

@@ -0,0 +1,275 @@
+// Zoho Projects Tasks API Module
+// EU data center base: https://projectsapi.zoho.eu
+
+var API_BASE = 'https://projectsapi.zoho.eu';
+
+function makeRequest(token, method, path, params) {
+    var portalId = shadowman.config.value('portal_id');
+    if (!portalId) {
+        return null; // Let the caller handle missing portal ID
+    }
+
+    // Use Zoho Projects API domain. EU data center: projectsapi.zoho.eu
+    // Note: auth may return api_domain as www.zohoapis.eu (generic), but Projects
+    // requires its own domain.
+    var apiBase = 'https://projectsapi.zoho.eu';
+
+    // Replace placeholders in path
+    var url = path.replace('{PORTALID}', portalId);
+    var fullUrl = apiBase + url;
+
+    var headers = {
+        'Authorization': 'Zoho-oauthtoken ' + token,
+        'Content-Type': 'application/x-www-form-urlencoded'
+    };
+
+    var body = null;
+    var contentType = 'application/x-www-form-urlencoded';
+
+    if (method === 'GET' && params) {
+        var qs = [];
+        for (var key in params) {
+            if (params.hasOwnProperty(key) && params[key] !== undefined) {
+                qs.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key]));
+            }
+        }
+        if (qs.length > 0) {
+            fullUrl += '?' + qs.join('&');
+        }
+    } else if (method === 'POST' && params) {
+        var parts = [];
+        for (var key in params) {
+            if (params.hasOwnProperty(key) && params[key] !== undefined) {
+                parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key]));
+            }
+        }
+        body = parts.join('&');
+    }
+
+    var resp = shadowman.http.request(fullUrl, {
+        method: method,
+        body: body,
+        headers: headers,
+        contentType: contentType
+    });
+
+    if (resp.status === 401) {
+        // Token might have expired - signal to retry
+        return { _tokenExpired: true };
+    }
+
+    if (resp.status >= 400) {
+        shadowman.log.error('Zoho API error: ' + resp.status + ' ' + resp.body);
+        var errBody;
+        try {
+            errBody = JSON.parse(resp.body);
+        } catch (e) {
+            errBody = { error: resp.body };
+        }
+        return { error: 'Zoho API error (' + resp.status + '): ' + (errBody.description || errBody.message || errBody.error || resp.body) };
+    }
+
+    try {
+        return JSON.parse(resp.body);
+    } catch (e) {
+        return { error: 'Invalid JSON response: ' + resp.body };
+    }
+}
+
+// Retry wrapper - auto-refreshes token on 401
+function apiCall(token, method, path, params) {
+    var result = makeRequest(token, method, path, params);
+    if (result && result._tokenExpired) {
+        // Token expired, trigger a refresh via auth module
+        var auth = require('./auth');
+        var newToken = auth.getValidToken();
+        if (!newToken) {
+            return { error: 'Authentication expired and refresh failed. Re-run setup_auth.' };
+        }
+        return makeRequest(newToken, method, path, params);
+    }
+    return result;
+}
+
+function listProjects(token, index) {
+    var portalId = shadowman.config.value('portal_id');
+    if (!portalId) {
+        return { error: 'Missing portal_id in plugin config.' };
+    }
+    return apiCall(token, 'GET', '/restapi/portal/{PORTALID}/projects/', {
+        index: String(index)
+    });
+}
+
+function listTasks(token, projectId, filters) {
+    var portalId = shadowman.config.value('portal_id');
+    if (!portalId) {
+        return { error: 'Missing portal_id in plugin config.' };
+    }
+
+    var params = {};
+    if (filters.status) params.status = filters.status;
+    if (filters.priority) params.priority = filters.priority;
+    if (filters.owner) params.owner = filters.owner;
+    if (filters.index) params.index = String(filters.index);
+    if (filters.milestone_id) params.milestone_id = filters.milestone_id;
+    if (filters.tasklist_id) params.tasklist_id = filters.tasklist_id;
+
+    return apiCall(token, 'GET', '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/'
+        .replace('{PROJECTID}', projectId), params);
+}
+
+function getTask(token, projectId, taskId) {
+    var portalId = shadowman.config.value('portal_id');
+    if (!portalId) {
+        return { error: 'Missing portal_id in plugin config.' };
+    }
+    return apiCall(token, 'GET',
+        '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/'
+        .replace('{PROJECTID}', projectId)
+        .replace('{TASKID}', taskId), {});
+}
+
+function createTask(token, projectId, args) {
+    var portalId = shadowman.config.value('portal_id');
+    if (!portalId) {
+        return { error: 'Missing portal_id in plugin config.' };
+    }
+
+    var params = { name: args.name };
+    if (args.description) params.description = args.description;
+    if (args.person_responsible) params.person_responsible = String(args.person_responsible);
+    if (args.priority) params.priority = args.priority;
+    if (args.start_date) params.start_date = args.start_date;
+    if (args.end_date) params.end_date = args.end_date;
+    if (args.tasklist_id) params.tasklist_id = String(args.tasklist_id);
+    if (args.milestone_id) params.milestone_id = String(args.milestone_id);
+    if (args.percent_complete) params.percent_complete = String(args.percent_complete);
+
+    return apiCall(token, 'POST',
+        '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/'
+        .replace('{PROJECTID}', projectId), params);
+}
+
+function updateTask(token, projectId, taskId, args) {
+    var portalId = shadowman.config.value('portal_id');
+    if (!portalId) {
+        return { error: 'Missing portal_id in plugin config.' };
+    }
+
+    var params = {};
+    if (args.name) params.name = args.name;
+    if (args.description !== undefined) params.description = args.description;
+    if (args.person_responsible) params.person_responsible = String(args.person_responsible);
+    if (args.priority) params.priority = args.priority;
+    if (args.start_date) params.start_date = args.start_date;
+    if (args.end_date) params.end_date = args.end_date;
+    if (args.percent_complete !== undefined) params.percent_complete = String(args.percent_complete);
+
+    return apiCall(token, 'POST',
+        '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/'
+        .replace('{PROJECTID}', projectId)
+        .replace('{TASKID}', taskId), params);
+}
+
+function deleteTask(token, projectId, taskId) {
+    var portalId = shadowman.config.value('portal_id');
+    if (!portalId) {
+        return { error: 'Missing portal_id in plugin config.' };
+    }
+    return apiCall(token, 'DELETE',
+        '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/{TASKID}/'
+        .replace('{PROJECTID}', projectId)
+        .replace('{TASKID}', taskId), {});
+}
+
+function getMyTasks(token, filters) {
+    var portalId = shadowman.config.value('portal_id');
+    if (!portalId) {
+        return { error: 'Missing portal_id in plugin config.' };
+    }
+
+    var params = {};
+    if (filters.status) params.status = filters.status;
+    if (filters.priority) params.priority = filters.priority;
+    if (filters.index) params.index = String(filters.index);
+
+    return apiCall(token, 'GET', '/restapi/portal/{PORTALID}/mytasks/', params);
+}
+
+function searchTasks(token, args) {
+    // Zoho Projects doesn't have a dedicated search endpoint, so we search across all projects
+    var portalId = shadowman.config.value('portal_id');
+    if (!portalId) {
+        return { error: 'Missing portal_id in plugin config.' };
+    }
+
+    var query = args.query;
+    var projectId = args.project_id;
+
+    if (projectId) {
+        // Search within a specific project
+        var result = apiCall(token, 'GET',
+            '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/'
+            .replace('{PROJECTID}', projectId), {
+                index: '1'
+            });
+
+        if (result.error) return result;
+
+        var tasks = result.tasks || [];
+        var matched = [];
+        for (var i = 0; i < tasks.length; i++) {
+            var t = tasks[i];
+            if (t.name && t.name.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
+                matched.push(t);
+            } else if (t.description && t.description.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
+                matched.push(t);
+            }
+        }
+        return { tasks: matched, total: matched.length, query: query };
+    }
+
+    // Search across all projects - first get project list
+    var projects = apiCall(token, 'GET', '/restapi/portal/{PORTALID}/projects/', {});
+    if (projects.error) return projects;
+
+    var allTasks = [];
+    var projectList = projects.projects || [];
+
+    for (var p = 0; p < projectList.length; p++) {
+        var proj = projectList[p];
+        var taskResult = apiCall(token, 'GET',
+            '/restapi/portal/{PORTALID}/projects/{PROJECTID}/tasks/'
+            .replace('{PROJECTID}', String(proj.id)), {
+                index: '1'
+            });
+
+        if (taskResult.error) continue;
+
+        var tasks = taskResult.tasks || [];
+        for (var i = 0; i < tasks.length; i++) {
+            var t = tasks[i];
+            if (t.name && t.name.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
+                t._project_name = proj.name;
+                allTasks.push(t);
+            } else if (t.description && t.description.toLowerCase().indexOf(query.toLowerCase()) !== -1) {
+                t._project_name = proj.name;
+                allTasks.push(t);
+            }
+        }
+    }
+
+    return { tasks: allTasks, total: allTasks.length, query: query };
+}
+
+module.exports = {
+    listProjects: listProjects,
+    listTasks: listTasks,
+    getTask: getTask,
+    createTask: createTask,
+    updateTask: updateTask,
+    deleteTask: deleteTask,
+    getMyTasks: getMyTasks,
+    searchTasks: searchTasks
+};

+ 148 - 0
manifest.json

@@ -0,0 +1,148 @@
+{
+  "id": "zoho-tasks",
+  "name": "Zoho Tasks",
+  "description": "Zoho Projects task management integration for EU data center",
+  "version": "1.0.0",
+  "author": "Ferenc Szontagh & Zoë",
+  "sdkVersion": 1,
+  "entry": "index.js",
+  "runtime": "quickjs",
+  "permissions": {
+    "allowNetwork": true,
+    "allowStorage": true
+  },
+  "config": [
+    { "key": "client_id", "label": "Zoho OAuth Client ID", "required": true, "secret": true },
+    { "key": "client_secret", "label": "Zoho OAuth Client Secret", "required": true, "secret": true },
+    { "key": "portal_id", "label": "Zoho Projects Portal ID", "required": true, "secret": false }
+  ],
+  "tools": [
+    {
+      "name": "setup_auth",
+      "description": "Exchange a Zoho grant token for access + refresh tokens. Run this ONCE after getting a grant token from the Zoho Developer Console.",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "grant_token": { "type": "string", "description": "Grant token from Zoho Developer Console (Self Client → Generate)" }
+        },
+        "required": ["grant_token"]
+      }
+    },
+    {
+      "name": "auth_status",
+      "description": "Check authentication status and token validity",
+      "parameters": { "type": "object", "properties": {} }
+    },
+    {
+      "name": "list_projects",
+      "description": "List all accessible Zoho Projects in the portal",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "index": { "type": "number", "description": "Page index (default 1)" }
+        }
+      }
+    },
+    {
+      "name": "list_tasks",
+      "description": "List tasks from a Zoho Projects project. Supports filtering by status, priority, assignee, and date range.",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "project_id": { "type": "string", "description": "Zoho Projects project ID" },
+          "status": { "type": "string", "description": "Filter: all, completed, notcompleted (default: all)" },
+          "priority": { "type": "string", "description": "Filter: all, none, low, medium, high" },
+          "owner": { "type": "string", "description": "Filter: all or user ID" },
+          "index": { "type": "number", "description": "Page index (default 1)" }
+        },
+        "required": ["project_id"]
+      }
+    },
+    {
+      "name": "get_task",
+      "description": "Get full details of a single task",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "project_id": { "type": "string", "description": "Zoho Projects project ID" },
+          "task_id": { "type": "string", "description": "Task ID" }
+        },
+        "required": ["project_id", "task_id"]
+      }
+    },
+    {
+      "name": "create_task",
+      "description": "Create a new task in a Zoho Projects project",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "project_id": { "type": "string", "description": "Zoho Projects project ID" },
+          "name": { "type": "string", "description": "Task name/title" },
+          "description": { "type": "string", "description": "Task description (HTML allowed)" },
+          "person_responsible": { "type": "string", "description": "Owner user ID(s), comma-separated" },
+          "priority": { "type": "string", "description": "none, low, medium, high" },
+          "start_date": { "type": "string", "description": "Start date (MM-DD-YYYY format)" },
+          "end_date": { "type": "string", "description": "Due date (MM-DD-YYYY format)" },
+          "tasklist_id": { "type": "string", "description": "Task list ID to add the task to" }
+        },
+        "required": ["project_id", "name"]
+      }
+    },
+    {
+      "name": "update_task",
+      "description": "Update an existing task in Zoho Projects",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "project_id": { "type": "string", "description": "Zoho Projects project ID" },
+          "task_id": { "type": "string", "description": "Task ID to update" },
+          "name": { "type": "string", "description": "New task name" },
+          "description": { "type": "string", "description": "New description (HTML allowed)" },
+          "person_responsible": { "type": "string", "description": "New owner user ID(s), comma-separated" },
+          "priority": { "type": "string", "description": "none, low, medium, high" },
+          "start_date": { "type": "string", "description": "New start date (MM-DD-YYYY)" },
+          "end_date": { "type": "string", "description": "New due date (MM-DD-YYYY)" },
+          "percent_complete": { "type": "string", "description": "Completion percentage (0-100)" }
+        },
+        "required": ["project_id", "task_id"]
+      }
+    },
+    {
+      "name": "delete_task",
+      "description": "Delete a task from Zoho Projects",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "project_id": { "type": "string", "description": "Zoho Projects project ID" },
+          "task_id": { "type": "string", "description": "Task ID to delete" }
+        },
+        "required": ["project_id", "task_id"]
+      }
+    },
+    {
+      "name": "my_tasks",
+      "description": "Get tasks assigned to the authenticated user across all projects",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "status": { "type": "string", "description": "Filter: all, completed, notcompleted (default: all)" },
+          "priority": { "type": "string", "description": "Filter: all, none, low, medium, high" },
+          "index": { "type": "number", "description": "Page index (default 1)" }
+        }
+      }
+    },
+    {
+      "name": "search_tasks",
+      "description": "Search tasks by keyword across all projects in the portal",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "query": { "type": "string", "description": "Search term" },
+          "project_id": { "type": "string", "description": "Optional: limit search to a specific project" }
+        },
+        "required": ["query"]
+      }
+    }
+  ],
+  "skills": ["zoho-tasks"]
+}