Compare commits

...

10 Commits

Author SHA1 Message Date
f38b0188df 🚨 fix: resolve all linter and typecheck warnings across codebase 2026-04-07 14:42:24 -04:00
54ca910609 🔧 chore: add biome config for Tailwind CSS and CSS module declarations 2026-04-07 14:42:17 -04:00
fab0d2ff47 refactor(components): derive ImagePicker variant types from buttonVariants
Replace manually duplicated variant/size type literals with
VariantProps<typeof buttonVariants> for type safety and
consistency with the Button component.
2026-04-07 13:11:42 -04:00
a7716d87df fix(components): add unoptimized image prop, overflow containment, and accessibility
Add unoptimized prop to image preview to support blob URLs, contain
overflow on preview container, replace div with semantic section
element in DragDropContainer with aria-label, and import shared
image constants.
2026-04-07 13:11:35 -04:00
096f548ec3 refactor(page): extract AI handler helpers and add client-side image validation
Break down the monolithic handleAiCreate into focused helpers
(sendAiRequest, persistAiEvents, populateEventForm), add client-side
image file validation before upload, and use toast.promise finally
callback for loading state cleanup.
2026-04-07 13:11:26 -04:00
79f98ebfd3 refactor(api): simplify AI event route with extracted utilities and env-based model
Replace inline JSON extraction with the shared json-utils module,
extract chat response content parsing into a dedicated helper, make
the AI model configurable via AI_MODEL env var, and improve error
messages for production safety.
2026-04-07 13:11:15 -04:00
7bb4f2be9d refactor(types): strengthen Zod schemas with regex validation and derive CalendarEvent
Add regex-based data URL validation for images, compute binary size
from base64 for accurate 10MB limit, enforce datetime strings with
offset for start/end fields, and derive CalendarEvent from the AI
response item type to eliminate field duplication.
2026-04-07 13:11:05 -04:00
dc4204a740 refactor(lib): extract shared image constants and JSON parsing utilities
Move image extensions, MIME types, and size limit into a dedicated
constants module. Extract JSON-from-text parsing into a pure utility
function for reuse across the codebase.
2026-04-07 13:10:59 -04:00
a0a7e021a8 style: standardize import ordering, type imports, and formatting in source files
Sort imports alphabetically, convert value imports to type-only where
appropriate, normalize indentation to tabs, and sort exports
alphabetically across UI components, pages, and lib modules.
2026-04-07 13:10:49 -04:00
cbae9fa1c9 style: standardize formatting in opencode tooling files
Reformat JSON configs and TypeScript scripts to use consistent
tab indentation, semicolons, and double quotes.
2026-04-07 13:10:35 -04:00
50 changed files with 1372 additions and 1260 deletions

View File

@@ -1,6 +1,6 @@
{
"source": "/tmp/skill-selector-curated-1953505229",
"sourceType": "local",
"localPath": "/tmp/skill-selector-curated-1953505229/zod-validation-expert",
"installedAt": "2026-04-07T15:11:20.921Z"
"source": "/tmp/skill-selector-curated-1953505229",
"sourceType": "local",
"localPath": "/tmp/skill-selector-curated-1953505229/zod-validation-expert",
"installedAt": "2026-04-07T15:11:20.921Z"
}

View File

@@ -1,363 +1,341 @@
{
"$schema": "https://opencode.ai/schemas/agent-metadata.json",
"schema_version": "1.0.0",
"description": "Centralized metadata for OpenAgents Control agents. This file stores metadata that is not part of the OpenCode agent schema but is needed for registry management, installation, and documentation.",
"agents": {
"openagent": {
"id": "openagent",
"name": "OpenAgent",
"category": "core",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["universal", "coordination", "primary"],
"dependencies": [
"subagent:task-manager",
"subagent:batch-executor",
"subagent:documentation",
"subagent:contextscout",
"subagent:externalscout",
"context:standards-code",
"context:standards-docs",
"context:standards-tests",
"context:review-ref",
"context:delegation-ref",
"context:external-libraries-workflow"
]
},
"opencoder": {
"id": "opencoder",
"name": "OpenCoder",
"category": "core",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["development", "coding", "implementation"],
"dependencies": [
"subagent:documentation",
"subagent:task-manager",
"subagent:batch-executor",
"subagent:coder-agent",
"subagent:tester",
"subagent:reviewer",
"subagent:build-agent",
"subagent:contextscout",
"subagent:externalscout",
"context:standards-code",
"context:task-delegation-basics",
"context:component-planning",
"context:external-libraries-workflow"
]
},
"repo-manager": {
"id": "repo-manager",
"name": "Repo Manager",
"category": "meta",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["repository", "management", "orchestration"],
"dependencies": [
"subagent:task-manager",
"subagent:contextscout",
"subagent:documentation",
"subagent:coder-agent",
"subagent:tester",
"subagent:reviewer",
"subagent:build-agent"
]
},
"system-builder": {
"id": "system-builder",
"name": "System Builder",
"category": "meta",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["system-generation", "architecture", "scaffolding"],
"dependencies": [
"subagent:agent-generator",
"subagent:command-creator",
"subagent:domain-analyzer",
"subagent:context-organizer",
"subagent:workflow-designer"
]
},
"copywriter": {
"id": "copywriter",
"name": "Copywriter",
"category": "content",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["content", "marketing", "writing"],
"dependencies": [
"context:standards-docs"
]
},
"technical-writer": {
"id": "technical-writer",
"name": "Technical Writer",
"category": "content",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["documentation", "technical", "writing"],
"dependencies": [
"context:standards-docs"
]
},
"data-analyst": {
"id": "data-analyst",
"name": "Data Analyst",
"category": "data",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["data", "analysis", "visualization"],
"dependencies": []
},
"eval-runner": {
"id": "eval-runner",
"name": "Eval Runner",
"category": "testing",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["testing", "evaluation", "quality"],
"dependencies": [
"context:standards-tests"
]
},
"task-manager": {
"id": "task-manager",
"name": "TaskManager",
"category": "subagents/core",
"type": "subagent",
"version": "2.0.0",
"author": "opencode",
"tags": ["task-breakdown", "planning", "coordination"],
"dependencies": [
"context:task-delegation-basics"
]
},
"batch-executor": {
"id": "batch-executor",
"name": "BatchExecutor",
"category": "subagents/core",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["parallel-execution", "batch-management", "coordination"],
"dependencies": [
"subagent:coder-agent",
"subagent:task-manager"
]
},
"documentation": {
"id": "documentation",
"name": "DocWriter",
"category": "subagents/core",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["documentation", "writing"],
"dependencies": [
"context:standards-docs"
]
},
"contextscout": {
"id": "contextscout",
"name": "ContextScout",
"category": "subagents/core",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["context", "discovery", "search"],
"dependencies": []
},
"externalscout": {
"id": "externalscout",
"name": "ExternalScout",
"category": "subagents/core",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["external", "documentation", "search"],
"dependencies": []
},
"context-manager": {
"id": "context-manager",
"name": "ContextManager",
"category": "subagents/core",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["context", "management", "organization"],
"dependencies": []
},
"context-retriever": {
"id": "context-retriever",
"name": "Context Retriever",
"category": "subagents/core",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["context", "retrieval", "search"],
"dependencies": []
},
"coder-agent": {
"id": "coder-agent",
"name": "CoderAgent",
"category": "subagents/code",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["coding", "implementation"],
"dependencies": [
"context:standards-code"
]
},
"tester": {
"id": "tester",
"name": "TestEngineer",
"category": "subagents/code",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["testing", "tdd", "quality"],
"dependencies": [
"context:standards-tests"
]
},
"reviewer": {
"id": "reviewer",
"name": "CodeReviewer",
"category": "subagents/code",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["review", "security", "quality"],
"dependencies": [
"context:standards-code",
"context:review-ref"
]
},
"build-agent": {
"id": "build-agent",
"name": "BuildAgent",
"category": "subagents/code",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["build", "validation", "type-checking"],
"dependencies": []
},
"frontend-specialist": {
"id": "frontend-specialist",
"name": "OpenFrontendSpecialist",
"category": "subagents/development",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["frontend", "ui", "design"],
"dependencies": [
"context:standards-code"
]
},
"devops-specialist": {
"id": "devops-specialist",
"name": "OpenDevopsSpecialist",
"category": "subagents/development",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["devops", "ci-cd", "infrastructure"],
"dependencies": []
},
"agent-generator": {
"id": "agent-generator",
"name": "AgentGenerator",
"category": "subagents/system-builder",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["generation", "agents", "scaffolding"],
"dependencies": []
},
"command-creator": {
"id": "command-creator",
"name": "CommandCreator",
"category": "subagents/system-builder",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["commands", "generation", "scaffolding"],
"dependencies": []
},
"domain-analyzer": {
"id": "domain-analyzer",
"name": "DomainAnalyzer",
"category": "subagents/system-builder",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["analysis", "domain", "architecture"],
"dependencies": []
},
"context-organizer": {
"id": "context-organizer",
"name": "ContextOrganizer",
"category": "subagents/system-builder",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["context", "organization", "structure"],
"dependencies": []
},
"workflow-designer": {
"id": "workflow-designer",
"name": "WorkflowDesigner",
"category": "subagents/system-builder",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["workflow", "design", "architecture"],
"dependencies": []
},
"image-specialist": {
"id": "image-specialist",
"name": "Image Specialist",
"category": "subagents/utils",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["images", "editing", "generation"],
"dependencies": []
},
"simple-responder": {
"id": "simple-responder",
"name": "Simple Responder",
"category": "subagents/test",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["testing", "evaluation"],
"dependencies": []
}
},
"defaults": {
"agent": {
"version": "1.0.0",
"author": "opencode",
"type": "agent",
"tags": []
},
"subagent": {
"version": "1.0.0",
"author": "opencode",
"type": "subagent",
"tags": []
}
}
"$schema": "https://opencode.ai/schemas/agent-metadata.json",
"schema_version": "1.0.0",
"description": "Centralized metadata for OpenAgents Control agents. This file stores metadata that is not part of the OpenCode agent schema but is needed for registry management, installation, and documentation.",
"agents": {
"openagent": {
"id": "openagent",
"name": "OpenAgent",
"category": "core",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["universal", "coordination", "primary"],
"dependencies": [
"subagent:task-manager",
"subagent:batch-executor",
"subagent:documentation",
"subagent:contextscout",
"subagent:externalscout",
"context:standards-code",
"context:standards-docs",
"context:standards-tests",
"context:review-ref",
"context:delegation-ref",
"context:external-libraries-workflow"
]
},
"opencoder": {
"id": "opencoder",
"name": "OpenCoder",
"category": "core",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["development", "coding", "implementation"],
"dependencies": [
"subagent:documentation",
"subagent:task-manager",
"subagent:batch-executor",
"subagent:coder-agent",
"subagent:tester",
"subagent:reviewer",
"subagent:build-agent",
"subagent:contextscout",
"subagent:externalscout",
"context:standards-code",
"context:task-delegation-basics",
"context:component-planning",
"context:external-libraries-workflow"
]
},
"repo-manager": {
"id": "repo-manager",
"name": "Repo Manager",
"category": "meta",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["repository", "management", "orchestration"],
"dependencies": [
"subagent:task-manager",
"subagent:contextscout",
"subagent:documentation",
"subagent:coder-agent",
"subagent:tester",
"subagent:reviewer",
"subagent:build-agent"
]
},
"system-builder": {
"id": "system-builder",
"name": "System Builder",
"category": "meta",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["system-generation", "architecture", "scaffolding"],
"dependencies": [
"subagent:agent-generator",
"subagent:command-creator",
"subagent:domain-analyzer",
"subagent:context-organizer",
"subagent:workflow-designer"
]
},
"copywriter": {
"id": "copywriter",
"name": "Copywriter",
"category": "content",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["content", "marketing", "writing"],
"dependencies": ["context:standards-docs"]
},
"technical-writer": {
"id": "technical-writer",
"name": "Technical Writer",
"category": "content",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["documentation", "technical", "writing"],
"dependencies": ["context:standards-docs"]
},
"data-analyst": {
"id": "data-analyst",
"name": "Data Analyst",
"category": "data",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["data", "analysis", "visualization"],
"dependencies": []
},
"eval-runner": {
"id": "eval-runner",
"name": "Eval Runner",
"category": "testing",
"type": "agent",
"version": "1.0.0",
"author": "opencode",
"tags": ["testing", "evaluation", "quality"],
"dependencies": ["context:standards-tests"]
},
"task-manager": {
"id": "task-manager",
"name": "TaskManager",
"category": "subagents/core",
"type": "subagent",
"version": "2.0.0",
"author": "opencode",
"tags": ["task-breakdown", "planning", "coordination"],
"dependencies": ["context:task-delegation-basics"]
},
"batch-executor": {
"id": "batch-executor",
"name": "BatchExecutor",
"category": "subagents/core",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["parallel-execution", "batch-management", "coordination"],
"dependencies": ["subagent:coder-agent", "subagent:task-manager"]
},
"documentation": {
"id": "documentation",
"name": "DocWriter",
"category": "subagents/core",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["documentation", "writing"],
"dependencies": ["context:standards-docs"]
},
"contextscout": {
"id": "contextscout",
"name": "ContextScout",
"category": "subagents/core",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["context", "discovery", "search"],
"dependencies": []
},
"externalscout": {
"id": "externalscout",
"name": "ExternalScout",
"category": "subagents/core",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["external", "documentation", "search"],
"dependencies": []
},
"context-manager": {
"id": "context-manager",
"name": "ContextManager",
"category": "subagents/core",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["context", "management", "organization"],
"dependencies": []
},
"context-retriever": {
"id": "context-retriever",
"name": "Context Retriever",
"category": "subagents/core",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["context", "retrieval", "search"],
"dependencies": []
},
"coder-agent": {
"id": "coder-agent",
"name": "CoderAgent",
"category": "subagents/code",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["coding", "implementation"],
"dependencies": ["context:standards-code"]
},
"tester": {
"id": "tester",
"name": "TestEngineer",
"category": "subagents/code",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["testing", "tdd", "quality"],
"dependencies": ["context:standards-tests"]
},
"reviewer": {
"id": "reviewer",
"name": "CodeReviewer",
"category": "subagents/code",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["review", "security", "quality"],
"dependencies": ["context:standards-code", "context:review-ref"]
},
"build-agent": {
"id": "build-agent",
"name": "BuildAgent",
"category": "subagents/code",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["build", "validation", "type-checking"],
"dependencies": []
},
"frontend-specialist": {
"id": "frontend-specialist",
"name": "OpenFrontendSpecialist",
"category": "subagents/development",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["frontend", "ui", "design"],
"dependencies": ["context:standards-code"]
},
"devops-specialist": {
"id": "devops-specialist",
"name": "OpenDevopsSpecialist",
"category": "subagents/development",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["devops", "ci-cd", "infrastructure"],
"dependencies": []
},
"agent-generator": {
"id": "agent-generator",
"name": "AgentGenerator",
"category": "subagents/system-builder",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["generation", "agents", "scaffolding"],
"dependencies": []
},
"command-creator": {
"id": "command-creator",
"name": "CommandCreator",
"category": "subagents/system-builder",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["commands", "generation", "scaffolding"],
"dependencies": []
},
"domain-analyzer": {
"id": "domain-analyzer",
"name": "DomainAnalyzer",
"category": "subagents/system-builder",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["analysis", "domain", "architecture"],
"dependencies": []
},
"context-organizer": {
"id": "context-organizer",
"name": "ContextOrganizer",
"category": "subagents/system-builder",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["context", "organization", "structure"],
"dependencies": []
},
"workflow-designer": {
"id": "workflow-designer",
"name": "WorkflowDesigner",
"category": "subagents/system-builder",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["workflow", "design", "architecture"],
"dependencies": []
},
"image-specialist": {
"id": "image-specialist",
"name": "Image Specialist",
"category": "subagents/utils",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["images", "editing", "generation"],
"dependencies": []
},
"simple-responder": {
"id": "simple-responder",
"name": "Simple Responder",
"category": "subagents/test",
"type": "subagent",
"version": "1.0.0",
"author": "opencode",
"tags": ["testing", "evaluation"],
"dependencies": []
}
},
"defaults": {
"agent": {
"version": "1.0.0",
"author": "opencode",
"type": "agent",
"tags": []
},
"subagent": {
"version": "1.0.0",
"author": "opencode",
"type": "subagent",
"tags": []
}
}
}

View File

@@ -1,7 +1,7 @@
{
"description": "Additional context file paths - agents load this via @ reference for dynamic pathing",
"paths": {
"local": ".opencode/context",
"global": "~/.config/opencode/context"
}
"description": "Additional context file paths - agents load this via @ reference for dynamic pathing",
"paths": {
"local": ".opencode/context",
"global": "~/.config/opencode/context"
}
}

View File

@@ -19,517 +19,576 @@
* .tmp/tasks/completed/{feature-slug}/
*/
const fs = require('fs');
const path = require('path');
const fs = require("fs");
const path = require("path");
// Find project root (look for .git or package.json)
function findProjectRoot(): string {
let dir = process.cwd();
while (dir !== path.dirname(dir)) {
if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, 'package.json'))) {
return dir;
}
dir = path.dirname(dir);
}
return process.cwd();
let dir = process.cwd();
while (dir !== path.dirname(dir)) {
if (
fs.existsSync(path.join(dir, ".git")) ||
fs.existsSync(path.join(dir, "package.json"))
) {
return dir;
}
dir = path.dirname(dir);
}
return process.cwd();
}
const PROJECT_ROOT = findProjectRoot();
const TASKS_DIR = path.join(PROJECT_ROOT, '.tmp', 'tasks');
const COMPLETED_DIR = path.join(TASKS_DIR, 'completed');
const TASKS_DIR = path.join(PROJECT_ROOT, ".tmp", "tasks");
const COMPLETED_DIR = path.join(TASKS_DIR, "completed");
interface Task {
id: string;
name: string;
status: 'active' | 'completed' | 'blocked' | 'archived';
objective: string;
context_files: string[];
reference_files?: string[];
exit_criteria: string[];
subtask_count: number;
completed_count: number;
created_at: string;
completed_at: string | null;
id: string;
name: string;
status: "active" | "completed" | "blocked" | "archived";
objective: string;
context_files: string[];
reference_files?: string[];
exit_criteria: string[];
subtask_count: number;
completed_count: number;
created_at: string;
completed_at: string | null;
}
interface Subtask {
id: string;
seq: string;
title: string;
status: 'pending' | 'in_progress' | 'completed' | 'blocked';
depends_on: string[];
parallel: boolean;
context_files: string[];
reference_files?: string[];
acceptance_criteria: string[];
deliverables: string[];
agent_id: string | null;
suggested_agent?: string;
started_at: string | null;
completed_at: string | null;
completion_summary: string | null;
id: string;
seq: string;
title: string;
status: "pending" | "in_progress" | "completed" | "blocked";
depends_on: string[];
parallel: boolean;
context_files: string[];
reference_files?: string[];
acceptance_criteria: string[];
deliverables: string[];
agent_id: string | null;
suggested_agent?: string;
started_at: string | null;
completed_at: string | null;
completion_summary: string | null;
}
// Helpers
function getFeatureDirs(): string[] {
if (!fs.existsSync(TASKS_DIR)) return [];
return fs.readdirSync(TASKS_DIR).filter((f: string) => {
const fullPath = path.join(TASKS_DIR, f);
return fs.statSync(fullPath).isDirectory() && f !== 'completed';
});
if (!fs.existsSync(TASKS_DIR)) return [];
return fs.readdirSync(TASKS_DIR).filter((f: string) => {
const fullPath = path.join(TASKS_DIR, f);
return fs.statSync(fullPath).isDirectory() && f !== "completed";
});
}
function loadTask(feature: string): Task | null {
const taskPath = path.join(TASKS_DIR, feature, 'task.json');
if (!fs.existsSync(taskPath)) return null;
return JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
const taskPath = path.join(TASKS_DIR, feature, "task.json");
if (!fs.existsSync(taskPath)) return null;
return JSON.parse(fs.readFileSync(taskPath, "utf-8"));
}
function loadSubtasks(feature: string): Subtask[] {
const featureDir = path.join(TASKS_DIR, feature);
if (!fs.existsSync(featureDir)) return [];
const featureDir = path.join(TASKS_DIR, feature);
if (!fs.existsSync(featureDir)) return [];
const files = fs.readdirSync(featureDir)
.filter((f: string) => f.match(/^subtask_\d{2}\.json$/))
.sort();
const files = fs
.readdirSync(featureDir)
.filter((f: string) => f.match(/^subtask_\d{2}\.json$/))
.sort();
return files.map((f: string) => JSON.parse(fs.readFileSync(path.join(featureDir, f), 'utf-8')));
return files.map((f: string) =>
JSON.parse(fs.readFileSync(path.join(featureDir, f), "utf-8")),
);
}
function saveSubtask(feature: string, subtask: Subtask): void {
const subtaskPath = path.join(TASKS_DIR, feature, `subtask_${subtask.seq}.json`);
fs.writeFileSync(subtaskPath, JSON.stringify(subtask, null, 2));
const subtaskPath = path.join(
TASKS_DIR,
feature,
`subtask_${subtask.seq}.json`,
);
fs.writeFileSync(subtaskPath, JSON.stringify(subtask, null, 2));
}
function saveTask(feature: string, task: Task): void {
const taskPath = path.join(TASKS_DIR, feature, 'task.json');
fs.writeFileSync(taskPath, JSON.stringify(task, null, 2));
const taskPath = path.join(TASKS_DIR, feature, "task.json");
fs.writeFileSync(taskPath, JSON.stringify(task, null, 2));
}
// Commands
function cmdStatus(feature?: string): void {
const features = feature ? [feature] : getFeatureDirs();
const features = feature ? [feature] : getFeatureDirs();
if (features.length === 0) {
console.log('No active features found.');
return;
}
if (features.length === 0) {
console.log("No active features found.");
return;
}
for (const f of features) {
const task = loadTask(f);
const subtasks = loadSubtasks(f);
for (const f of features) {
const task = loadTask(f);
const subtasks = loadSubtasks(f);
if (!task) {
console.log(`\n[${f}] - No task.json found`);
continue;
}
if (!task) {
console.log(`\n[${f}] - No task.json found`);
continue;
}
const counts = {
pending: subtasks.filter(s => s.status === 'pending').length,
in_progress: subtasks.filter(s => s.status === 'in_progress').length,
completed: subtasks.filter(s => s.status === 'completed').length,
blocked: subtasks.filter(s => s.status === 'blocked').length,
};
const counts = {
pending: subtasks.filter((s) => s.status === "pending").length,
in_progress: subtasks.filter((s) => s.status === "in_progress").length,
completed: subtasks.filter((s) => s.status === "completed").length,
blocked: subtasks.filter((s) => s.status === "blocked").length,
};
const progress = subtasks.length > 0
? Math.round((counts.completed / subtasks.length) * 100)
: 0;
const progress =
subtasks.length > 0
? Math.round((counts.completed / subtasks.length) * 100)
: 0;
console.log(`\n[${f}] ${task.name}`);
console.log(` Status: ${task.status} | Progress: ${progress}% (${counts.completed}/${subtasks.length})`);
console.log(` Pending: ${counts.pending} | In Progress: ${counts.in_progress} | Completed: ${counts.completed} | Blocked: ${counts.blocked}`);
}
console.log(`\n[${f}] ${task.name}`);
console.log(
` Status: ${task.status} | Progress: ${progress}% (${counts.completed}/${subtasks.length})`,
);
console.log(
` Pending: ${counts.pending} | In Progress: ${counts.in_progress} | Completed: ${counts.completed} | Blocked: ${counts.blocked}`,
);
}
}
function cmdNext(feature?: string): void {
const features = feature ? [feature] : getFeatureDirs();
const features = feature ? [feature] : getFeatureDirs();
console.log('\n=== Ready Tasks (deps satisfied) ===\n');
console.log("\n=== Ready Tasks (deps satisfied) ===\n");
for (const f of features) {
const subtasks = loadSubtasks(f);
const completedSeqs = new Set(subtasks.filter(s => s.status === 'completed').map(s => s.seq));
for (const f of features) {
const subtasks = loadSubtasks(f);
const completedSeqs = new Set(
subtasks.filter((s) => s.status === "completed").map((s) => s.seq),
);
const ready = subtasks.filter(s => {
if (s.status !== 'pending') return false;
return s.depends_on.every(dep => completedSeqs.has(dep));
});
const ready = subtasks.filter((s) => {
if (s.status !== "pending") return false;
return s.depends_on.every((dep) => completedSeqs.has(dep));
});
if (ready.length > 0) {
console.log(`[${f}]`);
for (const s of ready) {
const parallel = s.parallel ? '[parallel]' : '[sequential]';
console.log(` ${s.seq} - ${s.title} ${parallel}`);
}
console.log();
}
}
if (ready.length > 0) {
console.log(`[${f}]`);
for (const s of ready) {
const parallel = s.parallel ? "[parallel]" : "[sequential]";
console.log(` ${s.seq} - ${s.title} ${parallel}`);
}
console.log();
}
}
}
function cmdParallel(feature?: string): void {
const features = feature ? [feature] : getFeatureDirs();
const features = feature ? [feature] : getFeatureDirs();
console.log('\n=== Parallelizable Tasks Ready Now ===\n');
console.log("\n=== Parallelizable Tasks Ready Now ===\n");
for (const f of features) {
const subtasks = loadSubtasks(f);
const completedSeqs = new Set(subtasks.filter(s => s.status === 'completed').map(s => s.seq));
for (const f of features) {
const subtasks = loadSubtasks(f);
const completedSeqs = new Set(
subtasks.filter((s) => s.status === "completed").map((s) => s.seq),
);
const parallel = subtasks.filter(s => {
if (s.status !== 'pending') return false;
if (!s.parallel) return false;
return s.depends_on.every(dep => completedSeqs.has(dep));
});
const parallel = subtasks.filter((s) => {
if (s.status !== "pending") return false;
if (!s.parallel) return false;
return s.depends_on.every((dep) => completedSeqs.has(dep));
});
if (parallel.length > 0) {
console.log(`[${f}] - ${parallel.length} parallel tasks:`);
for (const s of parallel) {
console.log(` ${s.seq} - ${s.title}`);
}
console.log();
}
}
if (parallel.length > 0) {
console.log(`[${f}] - ${parallel.length} parallel tasks:`);
for (const s of parallel) {
console.log(` ${s.seq} - ${s.title}`);
}
console.log();
}
}
}
function cmdDeps(feature: string, seq: string): void {
const subtasks = loadSubtasks(feature);
const target = subtasks.find(s => s.seq === seq);
const subtasks = loadSubtasks(feature);
const target = subtasks.find((s) => s.seq === seq);
if (!target) {
console.log(`Task ${seq} not found in ${feature}`);
return;
}
if (!target) {
console.log(`Task ${seq} not found in ${feature}`);
return;
}
console.log(`\n=== Dependency Tree: ${feature}/${seq} ===\n`);
console.log(`${seq} - ${target.title} [${target.status}]`);
console.log(`\n=== Dependency Tree: ${feature}/${seq} ===\n`);
console.log(`${seq} - ${target.title} [${target.status}]`);
if (target.depends_on.length === 0) {
console.log(' └── (no dependencies)');
return;
}
if (target.depends_on.length === 0) {
console.log(" └── (no dependencies)");
return;
}
const printDeps = (seqs: string[], indent: string = ' '): void => {
for (let i = 0; i < seqs.length; i++) {
const depSeq = seqs[i];
const dep = subtasks.find(s => s.seq === depSeq);
const isLast = i === seqs.length - 1;
const branch = isLast ? '└──' : '├──';
const printDeps = (seqs: string[], indent: string = " "): void => {
for (let i = 0; i < seqs.length; i++) {
const depSeq = seqs[i];
const dep = subtasks.find((s) => s.seq === depSeq);
const isLast = i === seqs.length - 1;
const branch = isLast ? "└──" : "├──";
if (dep) {
const statusIcon = dep.status === 'completed' ? '✓' : dep.status === 'in_progress' ? '~' : '○';
console.log(`${indent}${branch} ${statusIcon} ${depSeq} - ${dep.title} [${dep.status}]`);
if (dep.depends_on.length > 0) {
const newIndent = indent + (isLast ? ' ' : '│ ');
printDeps(dep.depends_on, newIndent);
}
} else {
console.log(`${indent}${branch} ? ${depSeq} - NOT FOUND`);
}
}
};
if (dep) {
const statusIcon =
dep.status === "completed"
? "✓"
: dep.status === "in_progress"
? "~"
: "○";
console.log(
`${indent}${branch} ${statusIcon} ${depSeq} - ${dep.title} [${dep.status}]`,
);
if (dep.depends_on.length > 0) {
const newIndent = indent + (isLast ? " " : "│ ");
printDeps(dep.depends_on, newIndent);
}
} else {
console.log(`${indent}${branch} ? ${depSeq} - NOT FOUND`);
}
}
};
printDeps(target.depends_on);
printDeps(target.depends_on);
}
function cmdBlocked(feature?: string): void {
const features = feature ? [feature] : getFeatureDirs();
const features = feature ? [feature] : getFeatureDirs();
console.log('\n=== Blocked Tasks ===\n');
console.log("\n=== Blocked Tasks ===\n");
for (const f of features) {
const subtasks = loadSubtasks(f);
const completedSeqs = new Set(subtasks.filter(s => s.status === 'completed').map(s => s.seq));
for (const f of features) {
const subtasks = loadSubtasks(f);
const completedSeqs = new Set(
subtasks.filter((s) => s.status === "completed").map((s) => s.seq),
);
const blocked = subtasks.filter(s => {
if (s.status === 'blocked') return true;
if (s.status !== 'pending') return false;
return !s.depends_on.every(dep => completedSeqs.has(dep));
});
const blocked = subtasks.filter((s) => {
if (s.status === "blocked") return true;
if (s.status !== "pending") return false;
return !s.depends_on.every((dep) => completedSeqs.has(dep));
});
if (blocked.length > 0) {
console.log(`[${f}]`);
for (const s of blocked) {
const waitingFor = s.depends_on.filter(dep => !completedSeqs.has(dep));
const reason = s.status === 'blocked'
? 'explicitly blocked'
: `waiting: ${waitingFor.join(', ')}`;
console.log(` ${s.seq} - ${s.title} (${reason})`);
}
console.log();
}
}
if (blocked.length > 0) {
console.log(`[${f}]`);
for (const s of blocked) {
const waitingFor = s.depends_on.filter(
(dep) => !completedSeqs.has(dep),
);
const reason =
s.status === "blocked"
? "explicitly blocked"
: `waiting: ${waitingFor.join(", ")}`;
console.log(` ${s.seq} - ${s.title} (${reason})`);
}
console.log();
}
}
}
function cmdComplete(feature: string, seq: string, summary: string): void {
if (summary.length > 200) {
console.log('Error: Summary must be max 200 characters');
process.exit(1);
}
if (summary.length > 200) {
console.log("Error: Summary must be max 200 characters");
process.exit(1);
}
const subtasks = loadSubtasks(feature);
const subtask = subtasks.find(s => s.seq === seq);
const subtasks = loadSubtasks(feature);
const subtask = subtasks.find((s) => s.seq === seq);
if (!subtask) {
console.log(`Task ${seq} not found in ${feature}`);
process.exit(1);
}
if (!subtask) {
console.log(`Task ${seq} not found in ${feature}`);
process.exit(1);
}
subtask.status = 'completed';
subtask.completed_at = new Date().toISOString();
subtask.completion_summary = summary;
subtask.status = "completed";
subtask.completed_at = new Date().toISOString();
subtask.completion_summary = summary;
saveSubtask(feature, subtask);
saveSubtask(feature, subtask);
// Update task.json counts
const task = loadTask(feature);
if (task) {
const newSubtasks = loadSubtasks(feature);
task.completed_count = newSubtasks.filter(s => s.status === 'completed').length;
saveTask(feature, task);
}
// Update task.json counts
const task = loadTask(feature);
if (task) {
const newSubtasks = loadSubtasks(feature);
task.completed_count = newSubtasks.filter(
(s) => s.status === "completed",
).length;
saveTask(feature, task);
}
console.log(`\n✓ Marked ${feature}/${seq} as completed`);
console.log(` Summary: ${summary}`);
console.log(`\n✓ Marked ${feature}/${seq} as completed`);
console.log(` Summary: ${summary}`);
if (task) {
console.log(` Progress: ${task.completed_count}/${task.subtask_count}`);
}
if (task) {
console.log(` Progress: ${task.completed_count}/${task.subtask_count}`);
}
}
function cmdValidate(feature?: string): void {
const features = feature ? [feature] : getFeatureDirs();
let hasErrors = false;
const features = feature ? [feature] : getFeatureDirs();
let hasErrors = false;
const validTaskStatuses = new Set(['active', 'completed', 'blocked', 'archived']);
const validSubtaskStatuses = new Set(['pending', 'in_progress', 'completed', 'blocked']);
const validTaskStatuses = new Set([
"active",
"completed",
"blocked",
"archived",
]);
const validSubtaskStatuses = new Set([
"pending",
"in_progress",
"completed",
"blocked",
]);
const requiredTaskFields = [
'id',
'name',
'status',
'objective',
'context_files',
'exit_criteria',
'subtask_count',
'completed_count',
'created_at',
'completed_at',
];
const requiredTaskFields = [
"id",
"name",
"status",
"objective",
"context_files",
"exit_criteria",
"subtask_count",
"completed_count",
"created_at",
"completed_at",
];
const requiredSubtaskFields = [
'id',
'seq',
'title',
'status',
'depends_on',
'parallel',
'context_files',
'acceptance_criteria',
'deliverables',
'agent_id',
'started_at',
'completed_at',
'completion_summary',
];
const requiredSubtaskFields = [
"id",
"seq",
"title",
"status",
"depends_on",
"parallel",
"context_files",
"acceptance_criteria",
"deliverables",
"agent_id",
"started_at",
"completed_at",
"completion_summary",
];
const hasField = (obj: any, field: string): boolean => Object.prototype.hasOwnProperty.call(obj, field);
const isStringArray = (value: any): boolean => Array.isArray(value) && value.every(v => typeof v === 'string');
const hasField = (obj: any, field: string): boolean =>
Object.prototype.hasOwnProperty.call(obj, field);
const isStringArray = (value: any): boolean =>
Array.isArray(value) && value.every((v) => typeof v === "string");
console.log('\n=== Validation Results ===\n');
console.log("\n=== Validation Results ===\n");
for (const f of features) {
const errors: string[] = [];
for (const f of features) {
const errors: string[] = [];
// Check task.json exists
const task = loadTask(f);
if (!task) {
errors.push('Missing task.json');
}
// Check task.json exists
const task = loadTask(f);
if (!task) {
errors.push("Missing task.json");
}
// Load and validate subtasks
const subtasks = loadSubtasks(f);
const seqCounts = new Map<string, number>();
for (const s of subtasks) {
const seq = typeof s.seq === 'string' ? s.seq : '';
seqCounts.set(seq, (seqCounts.get(seq) || 0) + 1);
}
const seqs = new Set(subtasks.map(s => s.seq));
// Load and validate subtasks
const subtasks = loadSubtasks(f);
const seqCounts = new Map<string, number>();
for (const s of subtasks) {
const seq = typeof s.seq === "string" ? s.seq : "";
seqCounts.set(seq, (seqCounts.get(seq) || 0) + 1);
}
const seqs = new Set(subtasks.map((s) => s.seq));
if (task) {
// Required fields in task.json
for (const field of requiredTaskFields) {
if (!hasField(task, field)) {
errors.push(`task.json: missing required field '${field}'`);
}
}
if (task) {
// Required fields in task.json
for (const field of requiredTaskFields) {
if (!hasField(task, field)) {
errors.push(`task.json: missing required field '${field}'`);
}
}
// Task ID should match feature slug
if (task.id !== f) {
errors.push(`task.json id ('${task.id}') should match feature slug ('${f}')`);
}
// Task ID should match feature slug
if (task.id !== f) {
errors.push(
`task.json id ('${task.id}') should match feature slug ('${f}')`,
);
}
// Task status should be valid
if (!validTaskStatuses.has(task.status)) {
errors.push(`task.json: invalid status '${task.status}'`);
}
// Task status should be valid
if (!validTaskStatuses.has(task.status)) {
errors.push(`task.json: invalid status '${task.status}'`);
}
// Basic type checks for key task fields
if (!isStringArray(task.context_files)) {
errors.push('task.json: context_files must be string[]');
}
if (hasField(task, 'reference_files') && task.reference_files !== undefined && !isStringArray(task.reference_files)) {
errors.push('task.json: reference_files must be string[] when present');
}
if (!isStringArray(task.exit_criteria)) {
errors.push('task.json: exit_criteria must be string[]');
}
if (typeof task.subtask_count !== 'number') {
errors.push('task.json: subtask_count must be number');
}
if (typeof task.completed_count !== 'number') {
errors.push('task.json: completed_count must be number');
}
}
// Basic type checks for key task fields
if (!isStringArray(task.context_files)) {
errors.push("task.json: context_files must be string[]");
}
if (
hasField(task, "reference_files") &&
task.reference_files !== undefined &&
!isStringArray(task.reference_files)
) {
errors.push("task.json: reference_files must be string[] when present");
}
if (!isStringArray(task.exit_criteria)) {
errors.push("task.json: exit_criteria must be string[]");
}
if (typeof task.subtask_count !== "number") {
errors.push("task.json: subtask_count must be number");
}
if (typeof task.completed_count !== "number") {
errors.push("task.json: completed_count must be number");
}
}
for (const s of subtasks) {
// Required fields in subtask files
for (const field of requiredSubtaskFields) {
if (!hasField(s, field)) {
errors.push(`${s.seq || '??'}: missing required field '${field}'`);
}
}
for (const s of subtasks) {
// Required fields in subtask files
for (const field of requiredSubtaskFields) {
if (!hasField(s, field)) {
errors.push(`${s.seq || "??"}: missing required field '${field}'`);
}
}
// Sequence format and uniqueness
if (!/^\d{2}$/.test(s.seq)) {
errors.push(`${s.seq}: sequence must be 2 digits (e.g., 01, 02)`);
}
if ((seqCounts.get(s.seq) || 0) > 1) {
errors.push(`${s.seq}: duplicate sequence number`);
}
// Sequence format and uniqueness
if (!/^\d{2}$/.test(s.seq)) {
errors.push(`${s.seq}: sequence must be 2 digits (e.g., 01, 02)`);
}
if ((seqCounts.get(s.seq) || 0) > 1) {
errors.push(`${s.seq}: duplicate sequence number`);
}
// Check ID format
if (!s.id.startsWith(f)) {
errors.push(`${s.seq}: ID should start with feature name`);
}
// Check ID format
if (!s.id.startsWith(f)) {
errors.push(`${s.seq}: ID should start with feature name`);
}
// Status should be valid
if (!validSubtaskStatuses.has(s.status)) {
errors.push(`${s.seq}: invalid status '${s.status}'`);
}
// Status should be valid
if (!validSubtaskStatuses.has(s.status)) {
errors.push(`${s.seq}: invalid status '${s.status}'`);
}
// Type checks
if (!isStringArray(s.depends_on)) {
errors.push(`${s.seq}: depends_on must be string[]`);
}
if (typeof s.parallel !== 'boolean') {
errors.push(`${s.seq}: parallel must be boolean`);
}
if (!isStringArray(s.context_files)) {
errors.push(`${s.seq}: context_files must be string[]`);
}
if (hasField(s, 'reference_files') && s.reference_files !== undefined && !isStringArray(s.reference_files)) {
errors.push(`${s.seq}: reference_files must be string[] when present`);
}
if (!isStringArray(s.acceptance_criteria)) {
errors.push(`${s.seq}: acceptance_criteria must be string[]`);
} else if (s.acceptance_criteria.length === 0) {
errors.push(`${s.seq}: No acceptance criteria defined`);
}
if (!isStringArray(s.deliverables)) {
errors.push(`${s.seq}: deliverables must be string[]`);
} else if (s.deliverables.length === 0) {
errors.push(`${s.seq}: No deliverables defined`);
}
// Type checks
if (!isStringArray(s.depends_on)) {
errors.push(`${s.seq}: depends_on must be string[]`);
}
if (typeof s.parallel !== "boolean") {
errors.push(`${s.seq}: parallel must be boolean`);
}
if (!isStringArray(s.context_files)) {
errors.push(`${s.seq}: context_files must be string[]`);
}
if (
hasField(s, "reference_files") &&
s.reference_files !== undefined &&
!isStringArray(s.reference_files)
) {
errors.push(`${s.seq}: reference_files must be string[] when present`);
}
if (!isStringArray(s.acceptance_criteria)) {
errors.push(`${s.seq}: acceptance_criteria must be string[]`);
} else if (s.acceptance_criteria.length === 0) {
errors.push(`${s.seq}: No acceptance criteria defined`);
}
if (!isStringArray(s.deliverables)) {
errors.push(`${s.seq}: deliverables must be string[]`);
} else if (s.deliverables.length === 0) {
errors.push(`${s.seq}: No deliverables defined`);
}
// Self dependency is invalid
if (Array.isArray(s.depends_on) && s.depends_on.includes(s.seq)) {
errors.push(`${s.seq}: task cannot depend on itself`);
}
// Self dependency is invalid
if (Array.isArray(s.depends_on) && s.depends_on.includes(s.seq)) {
errors.push(`${s.seq}: task cannot depend on itself`);
}
// Check for missing dependencies
for (const dep of (Array.isArray(s.depends_on) ? s.depends_on : [])) {
if (!seqs.has(dep)) {
errors.push(`${s.seq}: depends on non-existent task ${dep}`);
}
}
// Check for missing dependencies
for (const dep of Array.isArray(s.depends_on) ? s.depends_on : []) {
if (!seqs.has(dep)) {
errors.push(`${s.seq}: depends on non-existent task ${dep}`);
}
}
// Check for circular dependencies
const visited = new Set<string>();
const checkCircular = (seq: string, path: string[]): boolean => {
if (path.includes(seq)) {
errors.push(`${s.seq}: circular dependency detected: ${[...path, seq].join(' -> ')}`);
return true;
}
if (visited.has(seq)) return false;
visited.add(seq);
// Check for circular dependencies
const visited = new Set<string>();
const checkCircular = (seq: string, path: string[]): boolean => {
if (path.includes(seq)) {
errors.push(
`${s.seq}: circular dependency detected: ${[...path, seq].join(" -> ")}`,
);
return true;
}
if (visited.has(seq)) return false;
visited.add(seq);
const task = subtasks.find(t => t.seq === seq);
if (task) {
for (const dep of task.depends_on) {
if (checkCircular(dep, [...path, seq])) return true;
}
}
return false;
};
checkCircular(s.seq, []);
}
const task = subtasks.find((t) => t.seq === seq);
if (task) {
for (const dep of task.depends_on) {
if (checkCircular(dep, [...path, seq])) return true;
}
}
return false;
};
checkCircular(s.seq, []);
}
// Check counts match
if (task && task.subtask_count !== subtasks.length) {
errors.push(`task.json subtask_count (${task.subtask_count}) doesn't match actual count (${subtasks.length})`);
}
// Check counts match
if (task && task.subtask_count !== subtasks.length) {
errors.push(
`task.json subtask_count (${task.subtask_count}) doesn't match actual count (${subtasks.length})`,
);
}
// Print results
console.log(`[${f}]`);
if (errors.length === 0) {
console.log(' ✓ All checks passed');
} else {
for (const e of errors) {
console.log(` ✗ ERROR: ${e}`);
hasErrors = true;
}
}
console.log();
}
// Print results
console.log(`[${f}]`);
if (errors.length === 0) {
console.log(" ✓ All checks passed");
} else {
for (const e of errors) {
console.log(` ✗ ERROR: ${e}`);
hasErrors = true;
}
}
console.log();
}
process.exit(hasErrors ? 1 : 0);
process.exit(hasErrors ? 1 : 0);
}
// Main
const [,, command, ...args] = process.argv;
const [, , command, ...args] = process.argv;
switch (command) {
case 'status':
cmdStatus(args[0]);
break;
case 'next':
cmdNext(args[0]);
break;
case 'parallel':
cmdParallel(args[0]);
break;
case 'deps':
if (args.length < 2) {
console.log('Usage: deps <feature> <seq>');
process.exit(1);
}
cmdDeps(args[0], args[1]);
break;
case 'blocked':
cmdBlocked(args[0]);
break;
case 'complete':
if (args.length < 3) {
console.log('Usage: complete <feature> <seq> "summary"');
process.exit(1);
}
cmdComplete(args[0], args[1], args.slice(2).join(' '));
break;
case 'validate':
cmdValidate(args[0]);
break;
default:
console.log(`
case "status":
cmdStatus(args[0]);
break;
case "next":
cmdNext(args[0]);
break;
case "parallel":
cmdParallel(args[0]);
break;
case "deps":
if (args.length < 2) {
console.log("Usage: deps <feature> <seq>");
process.exit(1);
}
cmdDeps(args[0], args[1]);
break;
case "blocked":
cmdBlocked(args[0]);
break;
case "complete":
if (args.length < 3) {
console.log('Usage: complete <feature> <seq> "summary"');
process.exit(1);
}
cmdComplete(args[0], args[1], args.slice(2).join(" "));
break;
case "validate":
cmdValidate(args[0]);
break;
default:
console.log(`
Task Management CLI
Usage: bunx --bun ts-node task-cli.ts <command> [feature] [args...]

View File

@@ -1,28 +1,28 @@
import { readFile } from "fs/promises"
import { resolve } from "path"
import { readFile } from "fs/promises";
import { resolve } from "path";
/**
* Configuration for environment variable loading
*/
export interface EnvLoaderConfig {
/** Custom paths to search for .env files (relative to current working directory) */
searchPaths?: string[]
/** Whether to log when environment variables are loaded */
verbose?: boolean
/** Whether to override existing environment variables */
override?: boolean
/** Custom paths to search for .env files (relative to current working directory) */
searchPaths?: string[];
/** Whether to log when environment variables are loaded */
verbose?: boolean;
/** Whether to override existing environment variables */
override?: boolean;
}
/**
* Default search paths for .env files
*/
const DEFAULT_ENV_PATHS = [
'./.env',
'../.env',
'../../.env',
'../plugin/.env',
'../../../.env'
]
"./.env",
"../.env",
"../../.env",
"../plugin/.env",
"../../../.env",
];
/**
* Load environment variables from .env files
@@ -31,54 +31,56 @@ const DEFAULT_ENV_PATHS = [
* @param config Configuration options
* @returns Object containing loaded environment variables
*/
export async function loadEnvVariables(config: EnvLoaderConfig = {}): Promise<Record<string, string>> {
const {
searchPaths = DEFAULT_ENV_PATHS,
verbose = false,
override = false
} = config
export async function loadEnvVariables(
config: EnvLoaderConfig = {},
): Promise<Record<string, string>> {
const {
searchPaths = DEFAULT_ENV_PATHS,
verbose = false,
override = false,
} = config;
const loadedVars: Record<string, string> = {}
const loadedVars: Record<string, string> = {};
for (const envPath of searchPaths) {
try {
const fullPath = resolve(envPath)
const content = await readFile(fullPath, 'utf8')
for (const envPath of searchPaths) {
try {
const fullPath = resolve(envPath);
const content = await readFile(fullPath, "utf8");
if (verbose) {
console.log(`Checking .env file: ${envPath}`)
}
if (verbose) {
console.log(`Checking .env file: ${envPath}`);
}
// Parse .env file content
const lines = content.split('\n')
for (const line of lines) {
const trimmed = line.trim()
if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=')) {
const [key, ...valueParts] = trimmed.split('=')
const value = valueParts.join('=').trim()
// Parse .env file content
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
const [key, ...valueParts] = trimmed.split("=");
const value = valueParts.join("=").trim();
// Remove quotes if present
const cleanValue = value.replace(/^["']|["']$/g, '')
// Remove quotes if present
const cleanValue = value.replace(/^["']|["']$/g, "");
if (key && cleanValue && (override || !process.env[key])) {
process.env[key] = cleanValue
loadedVars[key] = cleanValue
if (key && cleanValue && (override || !process.env[key])) {
process.env[key] = cleanValue;
loadedVars[key] = cleanValue;
if (verbose) {
console.log(`Loaded ${key} from ${envPath}`)
}
}
}
}
} catch (error) {
// File doesn't exist or can't be read, continue to next
if (verbose) {
console.log(`Could not read ${envPath}: ${error.message}`)
}
}
}
if (verbose) {
console.log(`Loaded ${key} from ${envPath}`);
}
}
}
}
} catch (error) {
// File doesn't exist or can't be read, continue to next
if (verbose) {
console.log(`Could not read ${envPath}: ${error.message}`);
}
}
}
return loadedVars
return loadedVars;
}
/**
@@ -88,17 +90,20 @@ export async function loadEnvVariables(config: EnvLoaderConfig = {}): Promise<Re
* @param config Configuration options
* @returns The environment variable value or null if not found
*/
export async function getEnvVariable(varName: string, config: EnvLoaderConfig = {}): Promise<string | null> {
// First check if it's already in the environment
let value = process.env[varName]
export async function getEnvVariable(
varName: string,
config: EnvLoaderConfig = {},
): Promise<string | null> {
// First check if it's already in the environment
let value = process.env[varName];
if (!value) {
// Try to load from .env files
const loadedVars = await loadEnvVariables(config)
value = loadedVars[varName] || process.env[varName]
}
if (!value) {
// Try to load from .env files
const loadedVars = await loadEnvVariables(config);
value = loadedVars[varName] || process.env[varName];
}
return value || null
return value || null;
}
/**
@@ -110,23 +115,30 @@ export async function getEnvVariable(varName: string, config: EnvLoaderConfig =
* @returns The environment variable value
* @throws Error if the variable is not found
*/
export async function getRequiredEnvVariable(varName: string, config: EnvLoaderConfig = {}): Promise<string> {
const value = await getEnvVariable(varName, config)
export async function getRequiredEnvVariable(
varName: string,
config: EnvLoaderConfig = {},
): Promise<string> {
const value = await getEnvVariable(varName, config);
if (!value) {
const searchPaths = config.searchPaths || DEFAULT_ENV_PATHS
throw new Error(`${varName} not found. Please set it in your environment or .env file.
if (!value) {
const searchPaths = config.searchPaths || DEFAULT_ENV_PATHS;
throw new Error(`${varName} not found. Please set it in your environment or .env file.
To fix this:
1. Add to .env file: ${varName}=your_value_here
2. Or export it: export ${varName}=your_value_here
Current working directory: ${process.cwd()}
Searched paths: ${searchPaths.join(', ')}
Environment variables available: ${Object.keys(process.env).filter(k => k.includes(varName.split('_')[0])).join(', ') || 'none matching'}`)
}
Searched paths: ${searchPaths.join(", ")}
Environment variables available: ${
Object.keys(process.env)
.filter((k) => k.includes(varName.split("_")[0]))
.join(", ") || "none matching"
}`);
}
return value
return value;
}
/**
@@ -137,22 +149,27 @@ Environment variables available: ${Object.keys(process.env).filter(k => k.includ
* @returns Object with variable names as keys and values as values
* @throws Error if any variable is not found
*/
export async function getRequiredEnvVariables(varNames: string[], config: EnvLoaderConfig = {}): Promise<Record<string, string>> {
const result: Record<string, string> = {}
export async function getRequiredEnvVariables(
varNames: string[],
config: EnvLoaderConfig = {},
): Promise<Record<string, string>> {
const result: Record<string, string> = {};
// Load all .env files first
await loadEnvVariables(config)
// Load all .env files first
await loadEnvVariables(config);
// Check each required variable
for (const varName of varNames) {
const value = process.env[varName]
if (!value) {
throw new Error(`Required environment variable ${varName} not found. Please set it in your environment or .env file.`)
}
result[varName] = value
}
// Check each required variable
for (const varName of varNames) {
const value = process.env[varName];
if (!value) {
throw new Error(
`Required environment variable ${varName} not found. Please set it in your environment or .env file.`,
);
}
result[varName] = value;
}
return result
return result;
}
/**
@@ -163,6 +180,9 @@ export async function getRequiredEnvVariables(varNames: string[], config: EnvLoa
* @returns The API key value
* @throws Error if the API key is not found
*/
export async function getApiKey(apiKeyName: string, config: EnvLoaderConfig = {}): Promise<string> {
return getRequiredEnvVariable(apiKeyName, config)
export async function getApiKey(
apiKeyName: string,
config: EnvLoaderConfig = {},
): Promise<string> {
return getRequiredEnvVariable(apiKeyName, config);
}

14
biome.json Normal file
View File

@@ -0,0 +1,14 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
"css": {
"linter": {
"enabled": true
},
"parser": {
"tailwindDirectives": true
},
"formatter": {
"enabled": true
}
}
}

View File

@@ -1,10 +1,11 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { extractJsonFromText } from "@/lib/json-utils";
import { openRouterClient } from "@/lib/openrouter-client";
import { AiEventRequestSchema, AiEventResponseSchema } from "@/lib/types";
const MODEL = "openai/gpt-5.4-mini";
const MODEL = process.env.AI_MODEL ?? "openai/gpt-5.4-mini";
const buildSystemPrompt = () => `
You are an assistant that converts natural language and images into an ARRAY of calendar events.
@@ -42,7 +43,26 @@ const callTextOnly = async (systemPrompt: string, prompt: string) => {
});
const rawResponse = await result.getText();
return { rawResponse, startTime: performance.now() };
return { rawResponse };
};
/** Extract the text content from an OpenRouter chat.send response. */
const extractContentFromChatResponse = (response: unknown): string => {
if (
typeof response === "object" &&
response !== null &&
"choices" in response
) {
const choices = (
response as {
choices: Array<{ message: { content: string | unknown } }>;
}
).choices;
const content = choices?.[0]?.message?.content;
if (typeof content === "string") return content;
if (content) return JSON.stringify(content);
}
throw new Error("Unexpected response format from AI chat API");
};
const callMultimodal = async (
@@ -70,8 +90,6 @@ const callMultimodal = async (
},
];
const startTime = performance.now();
const response = await openRouterClient.chat.send({
chatRequest: {
model: MODEL,
@@ -79,32 +97,8 @@ const callMultimodal = async (
},
});
const rawResponse =
typeof response === "object" &&
"choices" in response &&
response.choices?.[0]?.message
? typeof response.choices[0].message.content === "string"
? response.choices[0].message.content
: JSON.stringify(response.choices[0].message.content)
: JSON.stringify(response);
return { rawResponse, startTime };
};
const extractJsonFromText = (text: string): unknown => {
try {
return JSON.parse(text);
} catch {
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
if (codeBlockMatch) {
return JSON.parse(codeBlockMatch[1].trim());
}
const arrayMatch = text.match(/\[[\s\S]*\]/);
if (arrayMatch) {
return JSON.parse(arrayMatch[0]);
}
throw new Error(`No JSON found in response: ${text.slice(0, 200)}`);
}
const rawResponse = extractContentFromChatResponse(response);
return { rawResponse };
};
export async function POST(request: Request) {
@@ -133,25 +127,19 @@ export async function POST(request: Request) {
}
const { prompt, imageBase64 } = parsedInput.data;
const inputMode = imageBase64 ? "multimodal" : "text";
const systemPrompt = buildSystemPrompt();
let rawResponse: string | undefined;
try {
const result =
inputMode === "multimodal"
? await callMultimodal(systemPrompt, prompt, imageBase64!)
: await callTextOnly(systemPrompt, prompt!);
const result = imageBase64
? await callMultimodal(systemPrompt, prompt, imageBase64)
: await callTextOnly(systemPrompt, prompt ?? "");
rawResponse = result.rawResponse;
const rawJson = extractJsonFromText(rawResponse);
const rawJson = extractJsonFromText(result.rawResponse);
const validated = AiEventResponseSchema.safeParse(rawJson);
if (!validated.success) {
console.error("AI response validation failed:", {
issues: validated.error.flatten().fieldErrors,
rawResponse,
});
return NextResponse.json(
@@ -167,10 +155,7 @@ export async function POST(request: Request) {
} catch (error) {
console.error("AI Event Creation Error:", error);
return NextResponse.json(
{
error: "Failed to parse AI output",
raw: error instanceof Error ? error.message : String(error),
},
{ error: "Failed to process AI response. Please try again." },
{ status: 500 },
);
}

View File

@@ -1,6 +1,6 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { headers } from "next/headers";
import { openRouterClient } from "@/lib/openrouter-client";
export async function POST(request: Request) {

View File

@@ -1,4 +1,4 @@
import { auth } from "@/auth";
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/auth";
export const { GET, POST } = toNextJsHandler(auth);

View File

@@ -1,10 +1,10 @@
"use client";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
function Search() {
const searchParams = useSearchParams();

View File

@@ -1,6 +1,9 @@
"use client";
import { signIn, useSession } from "@/lib/auth-client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -9,10 +12,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { signIn, useSession } from "@/lib/auth-client";
export default function SignInPage() {
const { data: session, isPending } = useSession();
@@ -32,7 +32,7 @@ export default function SignInPage() {
providerId: "authentik",
callbackURL: "/",
});
} catch (_error) {
} catch {
toast.error("Failed to sign in. Please try again.");
} finally {
setIsLoading(false);

View File

@@ -1,6 +1,8 @@
"use client";
import { signOut, useSession } from "@/lib/auth-client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -9,9 +11,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { signOut, useSession } from "@/lib/auth-client";
export default function SignOutPage() {
const { data: session, isPending } = useSession();

View File

@@ -4,159 +4,174 @@
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.9232 0.0026 48.7171);
--foreground: oklch(0.2795 0.0368 260.0310);
--card: oklch(0.9699 0.0013 106.4238);
--card-foreground: oklch(0.2795 0.0368 260.0310);
--popover: oklch(0.9699 0.0013 106.4238);
--popover-foreground: oklch(0.2795 0.0368 260.0310);
--primary: oklch(0.5854 0.2041 277.1173);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.8687 0.0043 56.3660);
--secondary-foreground: oklch(0.4461 0.0263 256.8018);
--muted: oklch(0.9232 0.0026 48.7171);
--muted-foreground: oklch(0.5510 0.0234 264.3637);
--accent: oklch(0.9376 0.0260 321.9388);
--accent-foreground: oklch(0.3729 0.0306 259.7328);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.8687 0.0043 56.3660);
--input: oklch(0.8687 0.0043 56.3660);
--ring: oklch(0.5854 0.2041 277.1173);
--chart-1: oklch(0.5854 0.2041 277.1173);
--chart-2: oklch(0.5106 0.2301 276.9656);
--chart-3: oklch(0.4568 0.2146 277.0229);
--chart-4: oklch(0.3984 0.1773 277.3662);
--chart-5: oklch(0.3588 0.1354 278.6973);
--sidebar: oklch(0.8687 0.0043 56.3660);
--sidebar-foreground: oklch(0.2795 0.0368 260.0310);
--sidebar-primary: oklch(0.5854 0.2041 277.1173);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.9376 0.0260 321.9388);
--sidebar-accent-foreground: oklch(0.3729 0.0306 259.7328);
--sidebar-border: oklch(0.8687 0.0043 56.3660);
--sidebar-ring: oklch(0.5854 0.2041 277.1173);
--font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: Lora, serif;
--font-mono: Roboto Mono, monospace;
--radius: 1.25rem;
--shadow-2xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09);
--shadow-xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09);
--shadow-sm: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 1px 2px 3px hsl(240 4% 60% / 0.18);
--shadow: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 1px 2px 3px hsl(240 4% 60% / 0.18);
--shadow-md: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 2px 4px 3px hsl(240 4% 60% / 0.18);
--shadow-lg: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 4px 6px 3px hsl(240 4% 60% / 0.18);
--shadow-xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.18), 2px 8px 10px 3px hsl(240 4% 60% / 0.18);
--shadow-2xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.45);
--tracking-normal: 0em;
--spacing: 0.25rem;
--background: oklch(0.9232 0.0026 48.7171);
--foreground: oklch(0.2795 0.0368 260.031);
--card: oklch(0.9699 0.0013 106.4238);
--card-foreground: oklch(0.2795 0.0368 260.031);
--popover: oklch(0.9699 0.0013 106.4238);
--popover-foreground: oklch(0.2795 0.0368 260.031);
--primary: oklch(0.5854 0.2041 277.1173);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.8687 0.0043 56.366);
--secondary-foreground: oklch(0.4461 0.0263 256.8018);
--muted: oklch(0.9232 0.0026 48.7171);
--muted-foreground: oklch(0.551 0.0234 264.3637);
--accent: oklch(0.9376 0.026 321.9388);
--accent-foreground: oklch(0.3729 0.0306 259.7328);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.8687 0.0043 56.366);
--input: oklch(0.8687 0.0043 56.366);
--ring: oklch(0.5854 0.2041 277.1173);
--chart-1: oklch(0.5854 0.2041 277.1173);
--chart-2: oklch(0.5106 0.2301 276.9656);
--chart-3: oklch(0.4568 0.2146 277.0229);
--chart-4: oklch(0.3984 0.1773 277.3662);
--chart-5: oklch(0.3588 0.1354 278.6973);
--sidebar: oklch(0.8687 0.0043 56.366);
--sidebar-foreground: oklch(0.2795 0.0368 260.031);
--sidebar-primary: oklch(0.5854 0.2041 277.1173);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.9376 0.026 321.9388);
--sidebar-accent-foreground: oklch(0.3729 0.0306 259.7328);
--sidebar-border: oklch(0.8687 0.0043 56.366);
--sidebar-ring: oklch(0.5854 0.2041 277.1173);
--font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: Lora, serif;
--font-mono: Roboto Mono, monospace;
--radius: 1.25rem;
--shadow-2xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09);
--shadow-xs: 2px 2px 10px 4px hsl(240 4% 60% / 0.09);
--shadow-sm:
2px 2px 10px 4px hsl(240 4% 60% / 0.18),
2px 1px 2px 3px hsl(240 4% 60% / 0.18);
--shadow:
2px 2px 10px 4px hsl(240 4% 60% / 0.18),
2px 1px 2px 3px hsl(240 4% 60% / 0.18);
--shadow-md:
2px 2px 10px 4px hsl(240 4% 60% / 0.18),
2px 2px 4px 3px hsl(240 4% 60% / 0.18);
--shadow-lg:
2px 2px 10px 4px hsl(240 4% 60% / 0.18),
2px 4px 6px 3px hsl(240 4% 60% / 0.18);
--shadow-xl:
2px 2px 10px 4px hsl(240 4% 60% / 0.18),
2px 8px 10px 3px hsl(240 4% 60% / 0.18);
--shadow-2xl: 2px 2px 10px 4px hsl(240 4% 60% / 0.45);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0.2244 0.0074 67.4370);
--foreground: oklch(0.9288 0.0126 255.5078);
--card: oklch(0.2801 0.0080 59.3379);
--card-foreground: oklch(0.9288 0.0126 255.5078);
--popover: oklch(0.2801 0.0080 59.3379);
--popover-foreground: oklch(0.9288 0.0126 255.5078);
--primary: oklch(0.5994 0.1568 47.5224);
--primary-foreground: oklch(0.2244 0.0074 67.4370);
--secondary: oklch(0.3359 0.0077 59.4197);
--secondary-foreground: oklch(0.8717 0.0093 258.3382);
--muted: oklch(0.2801 0.0080 59.3379);
--muted-foreground: oklch(0.7137 0.0192 261.3246);
--accent: oklch(0.3896 0.0074 59.4734);
--accent-foreground: oklch(0.8717 0.0093 258.3382);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(0.2244 0.0074 67.4370);
--border: oklch(0.3359 0.0077 59.4197);
--input: oklch(0.3359 0.0077 59.4197);
--ring: oklch(0.6801 0.1583 276.9349);
--chart-1: oklch(0.6801 0.1583 276.9349);
--chart-2: oklch(0.5854 0.2041 277.1173);
--chart-3: oklch(0.5106 0.2301 276.9656);
--chart-4: oklch(0.4568 0.2146 277.0229);
--chart-5: oklch(0.3984 0.1773 277.3662);
--sidebar: oklch(0.3359 0.0077 59.4197);
--sidebar-foreground: oklch(0.9288 0.0126 255.5078);
--sidebar-primary: oklch(0.6801 0.1583 276.9349);
--sidebar-primary-foreground: oklch(0.2244 0.0074 67.4370);
--sidebar-accent: oklch(0.3896 0.0074 59.4734);
--sidebar-accent-foreground: oklch(0.8717 0.0093 258.3382);
--sidebar-border: oklch(0.3359 0.0077 59.4197);
--sidebar-ring: oklch(0.6801 0.1583 276.9349);
--font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: Lora, serif;
--font-mono: Roboto Mono, monospace;
--radius: 1.25rem;
--shadow-2xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09);
--shadow-xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09);
--shadow-sm: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18);
--shadow: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18);
--shadow-md: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 2px 4px 3px hsl(0 0% 0% / 0.18);
--shadow-lg: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 4px 6px 3px hsl(0 0% 0% / 0.18);
--shadow-xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 8px 10px 3px hsl(0 0% 0% / 0.18);
--shadow-2xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.45);
--background: oklch(0.2244 0.0074 67.437);
--foreground: oklch(0.9288 0.0126 255.5078);
--card: oklch(0.2801 0.008 59.3379);
--card-foreground: oklch(0.9288 0.0126 255.5078);
--popover: oklch(0.2801 0.008 59.3379);
--popover-foreground: oklch(0.9288 0.0126 255.5078);
--primary: oklch(0.5994 0.1568 47.5224);
--primary-foreground: oklch(0.2244 0.0074 67.437);
--secondary: oklch(0.3359 0.0077 59.4197);
--secondary-foreground: oklch(0.8717 0.0093 258.3382);
--muted: oklch(0.2801 0.008 59.3379);
--muted-foreground: oklch(0.7137 0.0192 261.3246);
--accent: oklch(0.3896 0.0074 59.4734);
--accent-foreground: oklch(0.8717 0.0093 258.3382);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(0.2244 0.0074 67.437);
--border: oklch(0.3359 0.0077 59.4197);
--input: oklch(0.3359 0.0077 59.4197);
--ring: oklch(0.6801 0.1583 276.9349);
--chart-1: oklch(0.6801 0.1583 276.9349);
--chart-2: oklch(0.5854 0.2041 277.1173);
--chart-3: oklch(0.5106 0.2301 276.9656);
--chart-4: oklch(0.4568 0.2146 277.0229);
--chart-5: oklch(0.3984 0.1773 277.3662);
--sidebar: oklch(0.3359 0.0077 59.4197);
--sidebar-foreground: oklch(0.9288 0.0126 255.5078);
--sidebar-primary: oklch(0.6801 0.1583 276.9349);
--sidebar-primary-foreground: oklch(0.2244 0.0074 67.437);
--sidebar-accent: oklch(0.3896 0.0074 59.4734);
--sidebar-accent-foreground: oklch(0.8717 0.0093 258.3382);
--sidebar-border: oklch(0.3359 0.0077 59.4197);
--sidebar-ring: oklch(0.6801 0.1583 276.9349);
--font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: Lora, serif;
--font-mono: Roboto Mono, monospace;
--radius: 1.25rem;
--shadow-2xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09);
--shadow-xs: 2px 2px 10px 4px hsl(0 0% 0% / 0.09);
--shadow-sm:
2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18);
--shadow:
2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 1px 2px 3px hsl(0 0% 0% / 0.18);
--shadow-md:
2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 2px 4px 3px hsl(0 0% 0% / 0.18);
--shadow-lg:
2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 4px 6px 3px hsl(0 0% 0% / 0.18);
--shadow-xl:
2px 2px 10px 4px hsl(0 0% 0% / 0.18), 2px 8px 10px 3px hsl(0 0% 0% / 0.18);
--shadow-2xl: 2px 2px 10px 4px hsl(0 0% 0% / 0.45);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { Geist, Magra } from "next/font/google";
import "./globals.css";
import Link from "next/link";
import { ThemeProvider } from "next-themes";
import { ModeToggle } from "@/components/mode-toggle";
import SignIn from "@/components/sign-in";
import { Toaster } from "@/components/ui/sonner";
import Link from "next/link";
const geist = Geist({
subsets: ["latin", "cyrillic"],

View File

@@ -1,26 +1,25 @@
"use client";
import { useEffect, useState } from "react";
import { nanoid } from "nanoid";
import { useSession } from "@/lib/auth-client";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { AIToolbar } from "@/components/ai-toolbar";
import { DragDropContainer } from "@/components/drag-drop-container";
import { EventActionsToolbar } from "@/components/event-actions-toolbar";
import { EventDialog } from "@/components/event-dialog";
import { EventsList } from "@/components/events-list";
import { useSession } from "@/lib/auth-client";
import { IMAGE_MIME_TYPES, MAX_IMAGE_SIZE_BYTES } from "@/lib/constants";
import {
saveEvent as addEvent,
clearEvents,
deleteEvent,
getEvents as getAllEvents,
clearEvents,
updateEvent,
} from "@/lib/events-db";
import { parseICS, generateICS } from "@/lib/ical";
import { generateICS, parseICS } from "@/lib/ical";
import type { CalendarEvent } from "@/lib/types";
import { AIToolbar } from "@/components/ai-toolbar";
import { EventActionsToolbar } from "@/components/event-actions-toolbar";
import { EventsList } from "@/components/events-list";
import { EventDialog } from "@/components/event-dialog";
import { DragDropContainer } from "@/components/drag-drop-container";
const fileToBase64 = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -29,6 +28,16 @@ const fileToBase64 = (file: File): Promise<string> =>
reader.readAsDataURL(file);
});
const validateImageFile = (file: File): string | null => {
if (!IMAGE_MIME_TYPES.includes(file.type)) {
return "Only PNG, JPEG, and WebP images are supported.";
}
if (file.size > MAX_IMAGE_SIZE_BYTES) {
return "Image must be less than 10MB.";
}
return null;
};
export default function HomePage() {
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -79,6 +88,11 @@ export default function HomePage() {
};
const handleImageSelect = async (file: File) => {
const error = validateImageFile(file);
if (error) {
toast.error(error);
return;
}
const base64 = await fileToBase64(file);
setImageBase64(base64);
setImagePreview(URL.createObjectURL(file));
@@ -154,95 +168,84 @@ export default function HomePage() {
URL.revokeObjectURL(url);
};
// AI Create Event
const populateEventForm = (ev: CalendarEvent) => {
setTitle(ev.title || "");
setDescription(ev.description || "");
setLocation(ev.location || "");
setUrl(ev.url || "");
setStart(ev.start || "");
setEnd(ev.end || "");
setAllDay(ev.allDay || false);
setEditingId(null);
setRecurrenceRule(ev.recurrenceRule || undefined);
};
const persistAiEvents = async (data: CalendarEvent[]) => {
for (const ev of data) {
const newEvent: CalendarEvent = {
...ev,
id: nanoid(),
createdAt: new Date().toISOString(),
lastModified: new Date().toISOString(),
};
await addEvent(newEvent);
}
const stored = await getAllEvents();
setEvents(stored);
};
const sendAiRequest = async (): Promise<CalendarEvent[]> => {
const res = await fetch("/api/ai-event", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: aiPrompt,
imageBase64: imageBase64 || undefined,
}),
});
if (res.status === 401) {
throw new Error("Please sign in to use AI features.");
}
const data = await res.json();
if (!Array.isArray(data) || data.length === 0) {
throw new Error("AI did not return event data.");
}
return data;
};
const handleAiCreate = async () => {
if (!aiPrompt.trim() && !imageBase64) return;
setAiLoading(true);
const promise = (): Promise<{ message: string }> =>
new Promise(async (resolve, reject) => {
try {
const res = await fetch("/api/ai-event", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: aiPrompt,
imageBase64: imageBase64 || undefined,
}),
});
const promise = async (): Promise<{ message: string }> => {
const data = await sendAiRequest();
if (res.status === 401) {
setAiLoading(false);
reject({
message: "Please sign in to use AI features.",
});
return;
}
if (data.length === 1) {
populateEventForm(data[0]);
setAiPrompt("");
setDialogOpen(true);
handleImageClear();
return { message: "Event has been created!" };
}
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
if (data.length === 1) {
const ev = data[0];
setTitle(ev.title || "");
setDescription(ev.description || "");
setLocation(ev.location || "");
setUrl(ev.url || "");
setStart(ev.start || "");
setEnd(ev.end || "");
setAllDay(ev.allDay || false);
setEditingId(null);
setAiPrompt("");
setDialogOpen(true);
setRecurrenceRule(ev.recurrenceRule || undefined);
handleImageClear();
resolve({
message: "Event has been created!",
});
} else {
for (const ev of data) {
const newEvent = {
id: nanoid(),
...ev,
createdAt: new Date().toISOString(),
lastModified: new Date().toISOString(),
};
await addEvent(newEvent);
}
const stored = await getAllEvents();
setEvents(stored);
setAiPrompt("");
setSummary(`Added ${data.length} AI-generated events.`);
setSummaryUpdated(new Date().toLocaleString());
handleImageClear();
resolve({
message: "Events have been created!",
});
}
} else {
reject({
message: "AI did not return event data.",
});
}
} catch (err) {
console.error(err);
reject({
message: "Error from AI service.",
});
}
});
await persistAiEvents(data);
setAiPrompt("");
setSummary(`Added ${data.length} AI-generated events.`);
setSummaryUpdated(new Date().toLocaleString());
handleImageClear();
return { message: "Events have been created!" };
};
toast.promise(promise, {
loading: "Generating event...",
success: ({ message }) => {
return message;
},
error: ({ message }) => {
return message;
},
success: ({ message }) => message,
error: ({ message }) => message,
finally: () => setAiLoading(false),
});
setAiLoading(false);
};
// AI Summarize Events

View File

@@ -1,9 +1,9 @@
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card } from "@/components/ui/card";
import { ImagePicker } from "@/components/image-picker";
import { X } from "lucide-react";
import Image from "next/image";
import { ImagePicker } from "@/components/image-picker";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
interface AIToolbarProps {
isAuthenticated: boolean;
@@ -53,13 +53,14 @@ export const AIToolbar = ({
onChange={(e) => setAiPrompt(e.target.value)}
/>
{imagePreview && (
<div className="relative mt-2 inline-block">
<div className="relative mt-2 inline-block max-w-full overflow-hidden">
<Image
src={imagePreview}
alt="Attached event flyer"
className="h-20 rounded-md object-cover border"
width={80}
height={80}
unoptimized
/>
<Button
variant="destructive"

View File

@@ -1,7 +1,6 @@
import { ReactNode } from "react";
import type { ReactNode } from "react";
import { toast } from "sonner";
const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp"];
import { IMAGE_EXTENSIONS } from "@/lib/constants";
const getFileType = (file: File): "ics" | "image" | null => {
const name = file.name.toLowerCase();
@@ -53,7 +52,8 @@ export const DragDropContainer = ({
};
return (
<div
<section
aria-label="Drag and drop file import area"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
@@ -67,6 +67,6 @@ export const DragDropContainer = ({
Drag & Drop *.ics or an event screenshot here
</div>
</div>
</div>
</section>
);
};

View File

@@ -1,5 +1,5 @@
import { Button } from "@/components/ui/button";
import { IcsFilePicker } from "@/components/ics-file-picker";
import { Button } from "@/components/ui/button";
import type { CalendarEvent } from "@/lib/types";
interface EventActionsToolbarProps {

View File

@@ -1,13 +1,13 @@
import { Clock, LucideMapPin, MoreHorizontal } from "lucide-react";
import { RRuleDisplay } from "@/components/rrule-display";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { LucideMapPin, Clock, MoreHorizontal } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { RRuleDisplay } from "@/components/rrule-display";
import type { CalendarEvent } from "@/lib/types";
interface EventCardProps {

View File

@@ -1,3 +1,4 @@
import { RecurrencePicker } from "@/components/recurrence-picker";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -7,7 +8,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { RecurrencePicker } from "@/components/recurrence-picker";
interface EventDialogProps {
open: boolean;

View File

@@ -1,6 +1,6 @@
import { Calendar1Icon } from "lucide-react";
import { EventCard } from "./event-card";
import type { CalendarEvent } from "@/lib/types";
import { EventCard } from "./event-card";
interface EventsListProps {
events: CalendarEvent[];

View File

@@ -1,10 +1,9 @@
"use client";
import { Calendar } from "lucide-react";
import type React from "react";
import { useRef } from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "lucide-react";
interface IcsFilePickerProps {
onFileSelect?: (file: File) => void;
@@ -48,7 +47,6 @@ export function IcsFilePicker({
accept=".ics"
onChange={handleFileChange}
className="hidden"
aria-hidden="true"
/>
<Button
onClick={handleButtonClick}

View File

@@ -1,22 +1,15 @@
"use client";
import type { VariantProps } from "class-variance-authority";
import { ImageIcon } from "lucide-react";
import type React from "react";
import { useRef } from "react";
import { Button } from "@/components/ui/button";
import { ImageIcon } from "lucide-react";
import { Button, type buttonVariants } from "@/components/ui/button";
interface ImagePickerProps {
interface ImagePickerProps extends VariantProps<typeof buttonVariants> {
onFileSelect?: (file: File) => void;
className?: string;
children?: React.ReactNode;
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
size?: "default" | "sm" | "lg" | "icon";
disabled?: boolean;
}

View File

@@ -1,8 +1,8 @@
"use client";
import * as React from "react";
import { Moon, Sun, Monitor } from "lucide-react";
import { Monitor, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -10,7 +11,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
type Recurrence = {
freq: "NONE" | "DAILY" | "WEEKLY" | "MONTHLY";

View File

@@ -41,8 +41,8 @@ export function RRuleDisplayDetailed({
{showBadges && details.length > 0 && (
<div className="flex flex-wrap gap-1">
{details.map((detail, index) => (
<Badge key={index} variant="outline" className="text-xs">
{details.map((detail) => (
<Badge key={detail} variant="outline" className="text-xs">
{detail}
</Badge>
))}
@@ -159,7 +159,7 @@ function formatRRuleToHuman(rule: RecurrenceRule): string {
const [, num, dayCode] = match;
const dayName = dayNames[dayCode as keyof typeof dayNames];
if (num) {
const ordinal = getOrdinal(parseInt(num));
const ordinal = getOrdinal(parseInt(num, 10));
return `${ordinal} ${dayName}`;
}
return dayName;

View File

@@ -1,9 +1,9 @@
"use client";
import { signOut, useSession } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { signOut, useSession } from "@/lib/auth-client";
export default function SignIn() {
const { data: session, isPending } = useSession();
@@ -13,7 +13,7 @@ export default function SignIn() {
try {
await signOut();
router.push("/");
} catch (_error) {
} catch {
toast.error("Failed to sign out. Please try again.");
}
};

View File

@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type * as React from "react";
export function ThemeProvider({
children,

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,15 +1,18 @@
"use client";
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/lib/utils";
import * as React from "react";
import {
type DayButton,
DayPicker,
getDefaultClassNames,
} from "react-day-picker";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function Calendar({
className,

View File

@@ -1,4 +1,4 @@
import * as React from "react";
import type * as React from "react";
import { cn } from "@/lib/utils";
@@ -83,10 +83,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};

View File

@@ -1,8 +1,8 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,8 +1,8 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,8 +1,8 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
@@ -240,18 +240,18 @@ function DropdownMenuSubContent({
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};

View File

@@ -1,4 +1,4 @@
import * as React from "react";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,8 +1,8 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,7 +1,7 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();

View File

@@ -1,4 +1,4 @@
import * as React from "react";
import type * as React from "react";
import { cn } from "@/lib/utils";

4
src/css.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module "*.css" {
const content: Record<string, string>;
export default content;
}

View File

@@ -2,7 +2,11 @@ import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const connectionString = process.env.DATABASE_URL!;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL environment variable is required");
}
const client = postgres(connectionString, {
prepare: false,

View File

@@ -1,4 +1,4 @@
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),

View File

@@ -1,5 +1,5 @@
import { createAuthClient } from "better-auth/react";
import { genericOAuthClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
plugins: [genericOAuthClient()],

9
src/lib/constants.ts Normal file
View File

@@ -0,0 +1,9 @@
/** Shared constants for image handling across the app. */
export const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp"];
export const IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"];
export const IMAGE_ACCEPT = IMAGE_MIME_TYPES.join(",");
export const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB

View File

@@ -1,4 +1,4 @@
import { openDB, type IDBPDatabase } from "idb";
import { type IDBPDatabase, openDB } from "idb";
import type { CalendarEvent } from "@/lib/types";
const DB_NAME = "LocalCalEvents";

View File

@@ -1,12 +1,12 @@
import ICAL from "ical.js";
import type { CalendarEvent } from "@/lib/types";
import {
isRecur,
isTime,
isUtcOffset,
isBinary,
isDuration,
isPeriod,
isRecur,
isTime,
isUtcOffset,
} from "./ical-helpers";
function safeValueToString(

19
src/lib/json-utils.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Extract JSON from text that may contain prose, markdown code blocks, or raw JSON.
* Pure function — same input = same output, no side effects.
*/
export const extractJsonFromText = (text: string): unknown => {
try {
return JSON.parse(text);
} catch {
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
if (codeBlockMatch) {
return JSON.parse(codeBlockMatch[1].trim());
}
const arrayMatch = text.match(/\[[\s\S]*\]/);
if (arrayMatch) {
return JSON.parse(arrayMatch[0]);
}
throw new Error("No JSON found in response");
}
};

View File

@@ -1,12 +1,26 @@
import { z } from "zod";
import { MAX_IMAGE_SIZE_BYTES } from "@/lib/constants";
/** Validates that a base64 data URL string decodes to binary under the max size. */
const isValidImageSize = (val: string | undefined): boolean => {
if (!val) return true;
const base64Part = val.split(",")[1] ?? "";
const binarySize = Math.ceil(base64Part.length * 0.75);
return binarySize <= MAX_IMAGE_SIZE_BYTES;
};
export const AiEventRequestSchema = z
.object({
prompt: z.string().trim().max(2000).optional(),
imageBase64: z
.string()
.startsWith("data:", "Must be a valid data URL")
.max(10 * 1024 * 1024 * 1.37, "Image must be less than 10MB")
.regex(
/^data:image\/(png|jpeg|webp);base64,/,
"Must be a valid image data URL (PNG, JPEG, or WebP)",
)
.refine(isValidImageSize, {
message: "Image must be less than 10MB",
})
.optional(),
})
.refine((data) => data.prompt || data.imageBase64, {
@@ -21,25 +35,18 @@ export const AiEventResponseItemSchema = z.object({
description: z.string().optional(),
location: z.string().optional(),
url: z.string().optional(),
start: z.string(),
end: z.string().optional(),
start: z.string().datetime({ offset: true }),
end: z.string().datetime({ offset: true }).optional(),
allDay: z.boolean().optional(),
recurrenceRule: z.string().optional(),
});
export const AiEventResponseSchema = z.array(AiEventResponseItemSchema);
export type CalendarEvent = {
export type AiEventResponseItem = z.infer<typeof AiEventResponseItemSchema>;
export type CalendarEvent = AiEventResponseItem & {
id: string;
title: string;
description?: string;
location?: string;
url?: string;
start: string;
end?: string;
allDay?: boolean;
createdAt?: string;
lastModified?: string;
recurrenceRule?: string;
};

View File

@@ -1,4 +1,4 @@
import { clsx, type ClassValue } from "clsx";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {