Compare commits
10 Commits
4e09059a3d
...
f38b0188df
| Author | SHA1 | Date | |
|---|---|---|---|
| f38b0188df | |||
| 54ca910609 | |||
| fab0d2ff47 | |||
| a7716d87df | |||
| 096f548ec3 | |||
| 79f98ebfd3 | |||
| 7bb4f2be9d | |||
| dc4204a740 | |||
| a0a7e021a8 | |||
| cbae9fa1c9 |
@@ -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"
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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...]
|
||||
|
||||
194
.opencode/tool/env/index.ts
vendored
194
.opencode/tool/env/index.ts
vendored
@@ -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
14
biome.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
183
src/app/page.tsx
183
src/app/page.tsx
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from "react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
4
src/css.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module "*.css" {
|
||||
const content: Record<string, string>;
|
||||
export default content;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
9
src/lib/constants.ts
Normal 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
|
||||
@@ -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";
|
||||
|
||||
@@ -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
19
src/lib/json-utils.ts
Normal 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");
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
Reference in New Issue
Block a user