Skill: workingcoder/dida365-openapi (version 1.0.0)

--- name: dida365-openapi description: 基于滴答清单（Dida365）官方 OpenAPI 和 OAuth2 的任务管理 Skill，直连 dida365.com，不经过任何第三方服务，你的数据只在本地和滴答清单服务器之间传输。零第三方依赖，纯 Python 标准库实现。完整覆盖项目与任务的增删改查、完成、移动、筛选，支持标签、提醒、重复规则等丰富功能，并内置输入验证和灰色区域防护，确保每次 API 调用都安全可靠。详细文档与同类 Skill 对比见 GitHub：https://github.com/workingcoder/dida365-openapi 。Use when the agent needs to manage Dida365 tasks, projects, tags, reminders, or repeat rules through the official OpenAPI. metadata: openclaw: requires: bins: - python env: - DIDA365_CLIENT_ID - DIDA365_CLIENT_SECRET --- # Dida365 OpenAPI ## Overview Use this skill to call the documented Dida365 OpenAPI surface through the bundled Python CLI. Prefer `python scripts/dida365.py ...` over ad hoc `curl` because the bundled CLI already handles OAuth, config loading, exact project-name resolution, structured errors, and every documented endpoint. Use Python 3.9 or newer. The skill's default support surface is the documented API plus stable tested extensions that behaved consistently in real app/API round-trips: - task `kind` writes for `TEXT` and `NOTE` - task `tags` writes ## Boundaries This skill intentionally stays on the official Dida365 OpenAPI surface. It does not use browser-cookie private APIs. That means the following are explicit non-goals here: - standalone tag catalog / CRUD operations outside task create or update writes - project folder / group operations - full-account sync from private endpoints - private batch task/project operations - achievement or productivity-stat calculations from private data If a user asks for those, state that they are outside the official API-backed scope of this skill. ## Quick Start 1. Persist app config once: - `python scripts/dida365.py auth setup --client-id ... --client-secret ... --redirect-uri ...` 2. Preferred OAuth path: - `python scripts/dida365.py auth login-local --client-id ... --client-secret ...` - Open the URL printed to stderr and finish the browser flow. 3. Verify the connection: - `python scripts/dida365.py project data --project-id inbox` 4. Read or write data: - `python scripts/dida365.py project list` - `python scripts/dida365.py task create --project-id inbox --title "Example"` - `python scripts/dida365.py task completed --start-date ... --end-date ...` to query completed tasks across all projects Manual `authorize-url` / `exchange-code` flows remain supported; see `references/auth-and-config.md` when the localhost callback flow is not suitable. ## Command Rules - Use `auth` for OAuth helpers, token cache inspection, and token cleanup. - Use `project` for project CRUD and project data reads. - Use `task` for task CRUD, complete, move, completed-task queries, and filter queries. - Use `project data --project-id inbox` for inbox reads. Do not use `project get --project-id inbox`. - Use `--project-name` only when the user gave a human-readable project name. Resolution is exact match only. - Use `--json-file` or `--json` when the payload contains arrays or nested objects such as `items`, `reminders`, bulk move operations, or advanced filter bodies. - Treat first-class flags as the final override layer. `--json-file` loads a base payload, `--json` overrides it, and scalar flags override both. - After write operations such as `task create`, `task update`, and `task complete`, prefer a read-back step when the stored result matters. Use `task get` to verify fields like `reminders`, `repeatFlag`, `tags`, and `kind`. - Stable task `kind` writes are `TEXT` and `NOTE`. Use checklist `items` instead of direct `CHECKLIST` kind writes. - `task filter --tag-json` uses any-match / OR semantics in real testing, not all-match semantics. - Unfiltered `task filter` responses are capped at 200 rows for this endpoint on the tested account. Narrow the query window or add filters when completeness matters. - Use the enum values, date format, confirmed reminder patterns, and confirmed recurrence patterns from `references/api-reference.md`. Do not invent undocumented values. - The CLI blocks known gray-area reminder and recurrence values. If a pattern is not listed as confirmed in `references/api-reference.md`, do not assume it is safe. - Expect JSON on stdout for every command. Commands that allow successful empty responses normalize them to `{"ok": true}`. - Expect structured JSON errors on stderr for validation failures and non-2xx API responses. ## Auth And Config - Config precedence is CLI flags, then environment variables, then local files in `${XDG_CONFIG_HOME:-~/.config}/dida365-openapi/`. - `auth exchange-code` and `auth login-local` persist config and token state; `auth setup` persists config without performing OAuth. - See `references/auth-and-config.md` for environment variables, local file locations, and localhost callback details. ## Command Selection - Need the raw authorization link: use `auth authorize-url`. - Already have an OAuth `code`: use `auth exchange-code`. - Want the CLI to wait for the browser callback: use `auth login-local`. - Need to inspect current config or token state: use `auth status`. - Need to remove only the cached token: use `auth clear-token`. - Need inbox tasks or columns: use `project data --project-id inbox`. - Need one project by id or exact name: use `project get`. - Need tasks under a project plus columns: use `project data`. - Need today's tasks: use `task filter` with the local-day `startDate` / `endDate` window. - Need a weekly completion review: use `task completed` with a weekly date range and omit `--project-id` for an account-wide view. - Need an exact documented create/update payload: prefer `--json-file`. - Need a single move operation quickly: use `task move --from-project-id ... --to-project-id ... --task-id ...`. - Need multiple move operations: use `task move --json '[{...}, {...}]'`. - Need completed tasks across all projects: omit `--project-id` from `task completed`. ## References - Read [references/auth-and-config.md](references/auth-and-config.md) for OAuth flow details, config precedence, token cache rules, and local callback behavior. - Read [references/api-reference.md](references/api-reference.md) for endpoint coverage, field names, request-body quirks, documented schema notes, and confirmed `repeatFlag` patterns. - Read [references/examples.md](references/examples.md) for command examples covering common read/write flows.

path: README.md
content: # dida365-openapi

基于滴答清单官方 OpenAPI 和 OAuth2 的任务管理 Skill，直连 dida365.com，不经过任何第三方服务，你的数据只在本地和滴答清单服务器之间传输。零第三方依赖，纯 Python 标准库实现。完整覆盖项目与任务的增删改查、完成、移动、筛选，支持标签、提醒、重复规则等丰富功能，并内置输入验证和灰色区域防护，确保每次 API 调用都安全可靠。

## 功能概览

告诉你的 agent 一句话，剩下的它来搞定：

- "看看我有哪些项目" — 列出所有项目
- "看看我有哪些任务" — 列出所有任务
- "帮我建个任务：买牛奶" — 在收集箱快速创建任务
- "把买牛奶标记为完成" — 完成任务
- "看看收集箱里有什么任务" — 查看任务列表
- "帮我查一下这周完成了哪些任务" — 已完成任务回顾
- "把这个任务优先级设为高" — 更新任务属性
- "给这个任务加上「工作」标签" — 标签管理
- "把这个任务移到工作项目里" — 跨项目移动
- "这个任务设成每天重复" — 重复规则
- "提前 30 分钟提醒我" — 任务提醒
- "帮我建个新项目叫读书计划" — 项目创建
- "帮我删掉这个任务" — 任务删除
- "找一下标签是「工作」的任务" — 任务筛选
- ……

## 与 ClawHub 现有 Skill 的对比

ClawHub 上目前有 6 个同类 skill，以下是核心差异对比：

| 维度 | dida365-openapi（本 Skill） | dida-cli | dida365 (MCP) | dida365-cli | dida-coach | dida365-ticktick-agent | ticktick-official-cli |
| --- | --- | --- | --- | --- | --- | --- | --- |
| **认证方式** | 官方 OAuth2 | OAuth PKCE | 依赖 MCP 服务端 | 浏览器 Cookie | 依赖 MCP 服务端 | 浏览器 Cookie | 官方 OAuth2 |
| **API 来源** | 官方 OpenAPI | 官方 OpenAPI | 未明确 | 私有 API | 依赖 MCP | 私有 API | 官方 OpenAPI |
| **标签写入** | 支持 | 不支持 | 仅筛选 | 支持（私有 API） | 仅筛选 | 支持（私有 API） | 不明确 |
| **提醒模式** | 完整支持 + 模式验证 | 不支持 | 不明确 | 支持 | 支持 | 支持 | 支持 |
| **重复规则** | RRULE 完整支持 + 模式验证 | 不支持 | 部分 | 支持 | 支持 | 支持 | 支持 |
| **输入验证** | 日期/优先级/提醒/重复规则全面验证 | 基础 flag 验证 | 字段级 | Zod 验证 | 语义级 | 无 | Pydantic 验证 |
| **灰色区域防护** | 主动屏蔽不安全的 API 行为 | 无 | 无 | 无 | 无 | 无 | 无 |
| **外部依赖** | 零依赖（纯 Python 标准库） | Node.js + npm | MCP 服务端 | Node.js + npm | Python + MCP 服务端 | Node.js + npm | Python + httpx/typer/pydantic |
| **参考文档** | 3 份完整文档（API 参考 + 认证 + 示例） | README | SKILL.md | README | 11 份提示词 + 3 份参考 | 无 | API 文档 |
| **API 稳定性** | 高（官方 API） | 高 | 未知 | 低（私有 API 随时可能变更） | 取决于 MCP 服务端 | 低（私有 API） | 高 |

> 以上对比基于各 skill 在本 skill 提交时的版本，各 skill 后续可能会有更新。

### 核心优势总结

1. **官方 API + 零依赖**：使用官方 OAuth2 认证和官方 OpenAPI，不依赖私有接口或第三方 MCP 服务端，纯 Python 标准库实现，无需 `npm install` 或 `pip install`
2. **全面的输入验证**：日期格式、优先级值、提醒触发器、重复规则在发送到 API 前全部本地验证，防止静默失败
3. **灰色区域防护**：滴答清单 API 存在一些未文档化的边界行为，例如某些提醒值会被静默忽略、无日期任务设置重复规则会被清除。本 skill 在 CLI 层主动拦截这些已知的不可靠操作，避免看似成功实则无效的调用，这是其他 skill 都没有的能力
4. **丰富的功能覆盖**：支持标签写入、任务类型设置、提醒模式、重复规则等能力，覆盖日常任务管理的全部场景

## 快速开始

### 1. 安装 Skill

```bash
clawhub install dida365-openapi
```

### 2. 确认 Python 版本

需要 Python 3.9 或更高版本：

```bash
python --version
```

### 3. 注册滴答清单 OAuth 应用

前往[滴答清单开发者平台](https://developer.dida365.com)，创建一个 OAuth 应用，获取 `client_id` 和 `client_secret`。

将 redirect_uri 设置为：`http://127.0.0.1:36500/callback`

### 4. 登录认证

```bash
cd ~/.openclaw/skills/dida365-openapi
python scripts/dida365.py auth login-local \
  --client-id YOUR_CLIENT_ID \
  --client-secret YOUR_CLIENT_SECRET
```

在浏览器中打开 stderr 输出的 URL，完成授权后令牌会自动保存。

### 5. 验证连接

```bash
python scripts/dida365.py project data --project-id inbox
```

看到收集箱数据即表示配置成功。之后就可以直接让 agent 帮你操作滴答清单了。

## Agent 调用示例

你只需用自然语言和 agent 对话，agent 会在后台自动转换为以下 CLI 命令执行：

```bash
# "看看我有哪些项目"
python scripts/dida365.py project list

# "帮我建个任务：买菜"
python scripts/dida365.py task create --project-id inbox --title "买菜"

# "帮我查一下这周完成了哪些任务"
python scripts/dida365.py task completed \
  --start-date "2026-03-16T00:00:00+0800" \
  --end-date "2026-03-22T23:59:59+0800"

# "找一下标签是「工作」的任务"
python scripts/dida365.py task filter --tag-json '["工作"]'
```

## Skill 结构

```
dida365-openapi/
  SKILL.md                       # Skill 定义（OpenClaw frontmatter + agent 指令）
  scripts/
    dida365.py                   # CLI 入口
    dida365_lib/
      __init__.py
      cli.py                     # 命令解析、输入验证、所有端点处理
      client.py                  # API 客户端封装
      auth.py                    # OAuth2 认证流程
      http.py                    # HTTP 请求层（基于 urllib）
      config.py                  # 配置加载与持久化
      common.py                  # 通用工具函数
      errors.py                  # 结构化错误定义
  references/
    api-reference.md             # 字段定义、枚举值、已确认的模式
    auth-and-config.md           # OAuth 流程、配置优先级、环境变量
    examples.md                  # 常见工作流的命令示例
```

## 工作原理

这个 skill 是纯 API 封装，不包含业务逻辑。Agent 读取 `SKILL.md` 中的指令，通过 `python scripts/dida365.py <resource> <action> [flags]` 调用滴答清单 API。所有命令以结构化 JSON 输出到 stdout，错误输出到 stderr。

CLI 会在本地验证输入（日期格式、优先级值、提醒模式），并屏蔽已知的灰色区域 API 行为，确保操作可预期。


path: references/api-reference.md
content: # API Reference Notes

These notes describe the default support surface exposed by the skill.

## Covered Surface

The bundled CLI maps every documented endpoint from the provided Dida365 OpenAPI spec:

### OAuth

- `GET /oauth/authorize`
- `POST /oauth/token`

### Project

- `GET /open/v1/project`
- `GET /open/v1/project/{projectId}`
- `GET /open/v1/project/{projectId}/data`
- `POST /open/v1/project`
- `POST /open/v1/project/{projectId}`
- `DELETE /open/v1/project/{projectId}`

### Task

- `GET /open/v1/project/{projectId}/task/{taskId}`
- `POST /open/v1/task`
- `POST /open/v1/task/{taskId}`
- `POST /open/v1/project/{projectId}/task/{taskId}/complete`
- `DELETE /open/v1/project/{projectId}/task/{taskId}`
- `POST /open/v1/task/move`
- `POST /open/v1/task/completed`
- `POST /open/v1/task/filter`

## CLI Payload Rules

- `project create` and `project update` accept first-class scalar flags for `name`, `color`, `sortOrder`, `viewMode`, and `kind`.
- `task create` and `task update` accept first-class scalar flags for:
  - `title`
  - `content`
  - `desc`
  - `kind`
  - `isAllDay`
  - `startDate`
  - `dueDate`
  - `timeZone`
  - `repeatFlag`
  - `priority`
  - `sortOrder`
- `task create` and `task update` accept JSON arrays via:
  - `--reminders-json`
  - `--tags-json`
  - `--items-json`
- `task move` accepts either:
  - a single operation from `--from-project-*`, `--to-project-*`, and `--task-id`
  - a full JSON array via `--json` or `--json-file`
- `task completed` and `task filter` accept either:
  - repeated `--project-id` and `--project-name`
  - first-class date flags such as `--start-date` and `--end-date`
  - a full JSON object via `--json` or `--json-file`
- `task filter` also accepts first-class filter flags for:
  - `--priority-json`
  - `--tag-json`
  - `--status-json`

## Exact-Match Project Resolution

The CLI resolves `--project-name` by calling `GET /open/v1/project` and matching `name` exactly.

- No fuzzy match
- No prefix match
- No task-title lookup
- Multiple matches are treated as an error

## Documented Quirks Preserved By The CLI

- `task update` requires both the path `taskId` and body fields `id` and `projectId`.
- Real testing showed `task update` behaves like patch semantics: sending only `title` preserved previously stored `content`, `desc`, `priority`, `repeatFlag`, and `reminders` in the tested cases.
- Real testing showed `project update` behaves like patch semantics: sending only `name` preserved previously stored `color`, `viewMode`, and `kind` in the tested cases.
- The docs show `tags` in task responses but do not document task tag mutation fields. Real testing showed stable create/update behavior for tag arrays, so the CLI exposes `--tags-json` for task writes.
- Task-level `kind` writes are undocumented in the official text. Real testing showed stable `TEXT` and `NOTE` writes, so the CLI exposes `--kind` for those values but still does not expose gray-area direct `CHECKLIST` writes.
- Real tag-filter testing showed `task filter` uses any-match / OR semantics for `tag`, not all-match semantics.
- Real testing showed `task completed` can operate account-wide when `projectIds` is omitted.
- Real testing showed unfiltered `task filter` responses are capped at 200 rows for this endpoint on the tested account.
- Real testing showed `task delete` behaves like soft delete in normal projects: the task left project views and appeared in the app trash, but could still be read through `task get` immediately after deletion.
- Real testing showed inbox delete behavior depends on the project id form. Using the literal alias `inbox` was unreliable; using the concrete inbox project id behaved more like soft delete to trash.
- Real testing showed move behavior for inbox tasks should still be treated as unreliable.
- Columns are returned by project data reads, but no column CRUD endpoints are documented. The CLI keeps columns read-only.

## Date Format

The documented date-time format is:

`yyyy-MM-dd'T'HH:mm:ssZ`

Example:

`2019-11-13T03:00:00+0000`

Use that format for:

- `startDate`
- `dueDate`
- `completedTime`
- completed-task query date filters
- task-filter query date filters

Observed date/time rules confirmed in real API and app testing:

- For non-all-day tasks, `startDate == dueDate` behaves like a point-in-time task.
- For non-all-day tasks, `dueDate > startDate` behaves like a time-range task.
- For multi-day all-day tasks, the API uses `dueDate` as an exclusive upper bound: the stored `dueDate` is the local midnight immediately after the visible end date.
- The API may normalize returned timestamps to UTC while preserving `timeZone`.
- Non-documented date formats such as `2026-03-20 23:00:00` may be silently ignored instead of rejected. Use the documented format exactly.
- The CLI validates task and task-query date strings locally against the documented format to avoid silent API drops.
- For `task completed` queries, real boundary testing showed the `completedTime` window is inclusive on both ends.

## Confirmed `repeatFlag` Patterns

The following `repeatFlag` values were confirmed by reading tasks created in the official Dida365 client and by writing the same values back through the API.
This list intentionally includes only patterns whose meaning is directly expressed by the stored `repeatFlag`.

### Standard RRULE Patterns

- Every day: `RRULE:FREQ=DAILY;INTERVAL=1`
- Every 2 days: `RRULE:FREQ=DAILY;INTERVAL=2`
- Weekdays: `RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR`
- Every week on one day: `RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO`
- Weekly on selected weekdays: `RRULE:FREQ=WEEKLY;WKST=MO;INTERVAL=1;BYDAY=TU,WE,TH`
- Every 2 weeks on selected weekdays: `RRULE:FREQ=WEEKLY;WKST=MO;INTERVAL=2;BYDAY=MO,TH,SA`
- Weekends: `RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=SU,SA`
- Every 3 weeks: `RRULE:FREQ=WEEKLY;WKST=MO;INTERVAL=3;BYDAY=MO`
- Every month on the same day: `RRULE:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=16`
- Every month on the last day: `RRULE:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=-1`
- Every month on the first Monday: `RRULE:FREQ=MONTHLY;INTERVAL=1;BYDAY=MO;BYSETPOS=1`
- Every 4 months: `RRULE:FREQ=MONTHLY;INTERVAL=4;BYMONTHDAY=16`
- Every year on the same month/day: `RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=3;BYMONTHDAY=16`
- Every 5 years: `RRULE:FREQ=YEARLY;INTERVAL=5;BYMONTH=3;BYMONTHDAY=16`

### Dida365 Extensions On RRULE

- Legal workdays: `RRULE:FREQ=DAILY;INTERVAL=1;TT_SKIP=HOLIDAY,WEEKEND`
- Official holidays: `RRULE:FREQ=DAILY;INTERVAL=1;TT_SKIP=OFFICIAL_WORKDAY`
- Every 3 days, skip holidays: `RRULE:FREQ=DAILY;INTERVAL=3;TT_SKIP=HOLIDAY`
- Every 3 days, skip weekends and holidays: `RRULE:FREQ=DAILY;INTERVAL=3;TT_SKIP=WEEKEND,HOLIDAY`

### ERULE Patterns

- Forgetting curve: `ERULE:NAME=FORGETTINGCURVE;CYCLE=0`
- Custom calendar dates: `ERULE:NAME=CUSTOM;BYDATE=20260319,20260320,20260328,20260331`

### Lunar Patterns

- Lunar monthly: `LUNAR:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=28`
- Lunar yearly: `LUNAR:FREQ=YEARLY;INTERVAL=1;BYMONTH=1;BYMONTHDAY=28`

### Usage Notes

- `repeatFlag` is not limited to plain RFC-style `RRULE` values. Dida365 also returns and accepts `ERULE`, `LUNAR`, and `TT_SKIP`-extended rules.
- Some values encode the current task date, such as `BYMONTHDAY=16` or `BYMONTH=3;BYMONTHDAY=16`. When creating a new rule, adjust those date parts to match the task's intended schedule.
- The CLI preserves `--repeat-flag ""`, so an empty string can be used to clear a stored recurrence value.
- Real undated-task testing showed `repeatFlag` can be silently cleared instead of preserved. For reliable recurrence writes, use `repeatFlag` only on tasks that already have `startDate` and `dueDate`.
- The list above is based on confirmed round-trips. If a UI label implies extra semantics that are not visible in the stored `repeatFlag`, do not infer more than the literal rule string expresses.

## Confirmed Reminder Patterns

The following reminder trigger values were confirmed through real API calls and Chinese-locale app checks.

- On time: `TRIGGER:PT0S`
- 5 minutes early: `TRIGGER:-PT5M`
- 30 minutes early: `TRIGGER:-PT30M`
- 1 hour early: `TRIGGER:-PT1H`
- 1 hour early: `TRIGGER:-PT60M`
- 1 day early: `TRIGGER:-PT1440M`
- All-day task, same day 9:00: `TRIGGER:P0DT9H0M0S`
- All-day task, same day 18:00: `TRIGGER:P0DT18H0M0S`

Usage notes:

- For multi-reminder tasks, app display order may differ from API array order.
- For all-day tasks with custom reminder times, the API stores offsets relative to the local-midnight boundary of the all-day task.
- `TRIGGER:-P1DT0H0M0S` is intentionally excluded from the stable list because real testing showed inconsistent app labeling.

## Intentionally Excluded Gray-Area Patterns

The skill intentionally does not expose the following pattern families as normal supported usage:

- Reminder `TRIGGER:-P1DT0H0M0S`
  - The API stored this value, but real Chinese-locale app checks labeled it as "2 days early" rather than "1 day early".
- Monthly weekday recurrence with `BYSETPOS != 1`
  - The API stored these rules and calendar placement was correct, but the app repeat label was wrong for tested values such as second, third, fourth, and last Monday.

## Definitions

### Enum Values

- Task priority: `0` none, `1` low, `3` medium, `5` high
- Task status: `0` normal, `2` completed
- Checklist item status: `0` normal, `1` completed
- Task kind: `TEXT`, `NOTE`, `CHECKLIST`
- Project kind: `TASK`, `NOTE`
- Project viewMode: `list`, `kanban`, `timeline`
- Project permission: `read`, `write`, `comment`

### ChecklistItem

- `id`: subtask identifier. In real update testing, checklist item ids were not stable across task updates; the API reassigned them after item edits.
- `title`: subtask title
- `status`: checklist item status, `0` normal or `1` completed
- `completedTime`: completion timestamp in the documented date format. In real testing this field was not stable for checklist items; completed status should be determined from `status`, not from `completedTime`.
- `isAllDay`: all-day flag
- `sortOrder`: subtask sort order
- `startDate`: start timestamp in the documented date format
- `timeZone`: timezone used for the start timestamp

### Task

- `id`: task identifier
- `projectId`: parent project identifier
- `title`: task title
- `content`: task content
- `desc`: checklist description text
- `isAllDay`: all-day flag
- `startDate`: start timestamp in the documented date format
- `dueDate`: due timestamp in the documented date format
- `completedTime`: completion timestamp in the documented date format
- `timeZone`: timezone used by the task timestamps
- `priority`: priority enum `0/1/3/5`
- `status`: task status enum `0/2`
- `repeatFlag`: recurrence rule, such as `RRULE:FREQ=DAILY;INTERVAL=1`
- `reminders`: reminder trigger strings
- `tags`: task tags returned in some actual responses. In real testing, tasks tagged in the official app were returned with tag arrays such as `["t1"]`
- `sortOrder`: task sort order
- `items`: checklist items for checklist tasks
- `kind`: task type enum `TEXT`, `NOTE`, or `CHECKLIST`
- `etag`: opaque revision token returned by the API
- `modifiedTime`: last-modified timestamp returned by the API

### Project

- `id`: project identifier
- `name`: project name
- `color`: project color, such as `#F18181`
- `sortOrder`: project sort order
- `closed`: whether the project is closed
- `groupId`: group identifier when the project belongs to a group
- `viewMode`: view enum `list`, `kanban`, or `timeline`
- `permission`: permission enum `read`, `write`, or `comment`
- `kind`: project type enum `TASK` or `NOTE`

### Column

- `id`: column identifier
- `projectId`: parent project identifier
- `name`: column name
- `sortOrder`: column sort order

### ProjectData

- `project`: project metadata. For inbox reads, this field may be omitted in actual responses.
- `tasks`: undone tasks under the project
- `columns`: columns under the project

## Response Handling

- Successful JSON responses are printed to stdout unchanged.
- Commands/endpoints that allow successful empty responses are normalized to:

```json
{
  "ok": true
}
```

- Commands that are expected to return JSON entities raise an error if the API returns an empty `200` response.

- Non-2xx responses are printed to stderr as structured JSON that includes:
  - error type
  - message
  - HTTP status
  - method
  - URL
  - parsed response body when available


path: references/auth-and-config.md
content: # Auth And Config

## OAuth Flows

### Setup-only flow

Persist app settings before logging in:

```bash
python scripts/dida365.py auth setup \
  --client-id ... \
  --client-secret ... \
  --redirect-uri ...
```

This writes the provided non-default auth settings to `config.json` but does not contact the OAuth server.

### Manual flow

1. Run `python scripts/dida365.py auth authorize-url --client-id ... --redirect-uri ...`.
2. Open the returned `authorize_url` in a browser.
3. After Dida365 redirects back, copy the `code` query parameter.
4. Exchange it with `python scripts/dida365.py auth exchange-code --code ... --client-id ... --client-secret ... --redirect-uri ...`.

### Localhost flow

1. Run `python scripts/dida365.py auth login-local --client-id ... --client-secret ...`.
2. The CLI prints an authorization URL to stderr and starts an HTTP listener.
3. Open the URL in a browser and complete the OAuth flow.
4. The callback server captures `code`, verifies `state`, exchanges the code, and writes `token.json`.

`login-local` defaults to `http://127.0.0.1:36500/callback`. Override it with `--redirect-uri` only when the Dida365 app registration uses a different localhost callback URL.
Use Python 3.9 or newer for the bundled CLI.

## Config Precedence

The CLI resolves settings in this order:

1. CLI flags
2. Environment variables
3. Local files

This applies to:

- `client_id`
- `client_secret`
- `redirect_uri`
- `scope`
- `access_token`
- `auth_base_url`
- `api_base_url`

## Environment Variables

- `DIDA365_CLIENT_ID`
- `DIDA365_CLIENT_SECRET`
- `DIDA365_REDIRECT_URI`
- `DIDA365_SCOPE`
- `DIDA365_ACCESS_TOKEN`
- `DIDA365_AUTH_BASE_URL`
- `DIDA365_API_BASE_URL`

## Local Files

Default config directory:

`~/.config/dida365-openapi/`

Files:

- `config.json`: stores resolved auth settings such as `client_id`, `client_secret`, `redirect_uri`, `scope`, and optional base URLs.
- `token.json`: stores the raw token response from Dida365. The CLI only requires `access_token`, but it preserves any extra fields returned by the server.

The CLI redacts `client_secret`, `access_token`, and `refresh_token` when printing status to the terminal.

## Notes

- The bundled CLI does not auto-open a browser in v1. It prints the authorization URL and waits for the user to open it.
- The CLI does not implement automatic refresh-token handling in v1 because the provided official docs do not document a refresh flow.
- `auth clear-token` only deletes the cached token file. It does not modify environment variables.
- `auth setup` is useful when you want the agent to save resolved auth settings once and keep later commands shorter.


path: references/examples.md
content: # Examples

Commands below assume the current working directory is `dida365-openapi/`. If you run them from the repo root, replace `python scripts/dida365.py` with `python dida365-openapi/scripts/dida365.py`.
Examples below focus on the stable support surface exposed by this skill.

## Auth

Persist app config:

```bash
python scripts/dida365.py auth setup \
  --client-id "$DIDA365_CLIENT_ID" \
  --client-secret "$DIDA365_CLIENT_SECRET" \
  --redirect-uri "http://127.0.0.1:36500/callback"
```

Generate an authorization URL:

```bash
python scripts/dida365.py auth authorize-url \
  --client-id "$DIDA365_CLIENT_ID" \
  --redirect-uri "http://127.0.0.1:36500/callback"
```

Exchange an authorization code:

```bash
python scripts/dida365.py auth exchange-code \
  --code "returned-code" \
  --client-id "$DIDA365_CLIENT_ID" \
  --client-secret "$DIDA365_CLIENT_SECRET" \
  --redirect-uri "http://127.0.0.1:36500/callback"
```

## Project

List projects:

```bash
python scripts/dida365.py project list
```

Get one project by exact name:

```bash
python scripts/dida365.py project get --project-name "API Demo"
```

Read project data with the special inbox id:

```bash
python scripts/dida365.py project data --project-id inbox
```

Create a project:

```bash
python scripts/dida365.py project create \
  --name "API Demo" \
  --color "#F18181" \
  --view-mode list \
  --kind TASK
```

## Task

Create a simple task:

```bash
python scripts/dida365.py task create \
  --project-id inbox \
  --title "Review Dida365 OpenAPI skill"
```

Create a note-style task:

```bash
python scripts/dida365.py task create \
  --project-id inbox \
  --title "Research notes" \
  --kind NOTE
```

Create a tagged task:

```bash
python scripts/dida365.py task create \
  --project-id inbox \
  --title "Tagged task" \
  --tags-json '["alpha","beta"]'
```

Create a task from a JSON file:

```bash
python scripts/dida365.py task create --json-file ./task-create.json
```

Update a task with nested checklist items:

```bash
python scripts/dida365.py task update \
  --task-id "your-task-id" \
  --project-id "your-project-id" \
  --items-json '[{"title":"Item 1","status":0}]'
```

Complete a task:

```bash
python scripts/dida365.py task complete \
  --project-id "your-project-id" \
  --task-id "your-task-id"
```

Move one task:

```bash
python scripts/dida365.py task move \
  --from-project-id "source-project-id" \
  --to-project-id "destination-project-id" \
  --task-id "your-task-id"
```

Move many tasks with inline JSON:

```bash
python scripts/dida365.py task move \
  --json '[{"fromProjectId":"p1","toProjectId":"p2","taskId":"t1"},{"fromProjectId":"p1","toProjectId":"p3","taskId":"t2"}]'
```

List completed tasks in a time range:

```bash
python scripts/dida365.py task completed \
  --project-id "your-project-id" \
  --start-date "2026-03-01T00:58:20.000+0000" \
  --end-date "2026-03-05T10:58:20.000+0000"
```

List tasks completed in an explicit local time range:

```bash
python scripts/dida365.py task completed \
  --start-date "2026-03-17T00:00:00+0800" \
  --end-date "2026-03-17T23:59:59+0800"
```

Filter tasks by tag:

```bash
python scripts/dida365.py task filter \
  --tag-json '["urgent"]' \
  --status-json '[0]'
```

Filter both open and completed tasks:

```bash
python scripts/dida365.py task filter \
  --project-id "your-project-id" \
  --status-json '[0,2]'
```

Create a task with multiple reminders:

```bash
python scripts/dida365.py task create \
  --project-id inbox \
  --title "Reminder example" \
  --start-date "2026-03-21T22:00:00+0800" \
  --due-date "2026-03-21T22:00:00+0800" \
  --time-zone "Asia/Shanghai" \
  --reminders-json '["TRIGGER:PT0S","TRIGGER:-PT5M"]'
```

Update task tags:

```bash
python scripts/dida365.py task update \
  --project-id "your-project-id" \
  --task-id "your-task-id" \
  --tags-json '["new-tag"]'
```

Clear a repeat rule:

```bash
python scripts/dida365.py task update \
  --project-id "your-project-id" \
  --task-id "your-task-id" \
  --repeat-flag ""
```

## Repeat Rules

Create a weekday recurring task:

```bash
python scripts/dida365.py task create \
  --project-id inbox \
  --title "Weekday example" \
  --start-date "2026-03-16T22:00:00+0800" \
  --due-date "2026-03-16T22:00:00+0800" \
  --time-zone "Asia/Shanghai" \
  --repeat-flag "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR"
```

Update a task to repeat every 2 weeks on multiple weekdays:

```bash
python scripts/dida365.py task update \
  --project-id "your-project-id" \
  --task-id "your-task-id" \
  --repeat-flag "RRULE:FREQ=WEEKLY;WKST=MO;INTERVAL=2;BYDAY=MO,TH,SA"
```

Set custom explicit repeat dates:

```bash
python scripts/dida365.py task update \
  --project-id "your-project-id" \
  --task-id "your-task-id" \
  --repeat-flag "ERULE:NAME=CUSTOM;BYDATE=20260319,20260320,20260328,20260331"
```

Set a lunar yearly rule:

```bash
python scripts/dida365.py task update \
  --project-id "your-project-id" \
  --task-id "your-task-id" \
  --repeat-flag "LUNAR:FREQ=YEARLY;INTERVAL=1;BYMONTH=1;BYMONTHDAY=28"
```

Set a Dida365 extended skip rule:

```bash
python scripts/dida365.py task update \
  --project-id "your-project-id" \
  --task-id "your-task-id" \
  --repeat-flag "RRULE:FREQ=DAILY;INTERVAL=3;TT_SKIP=WEEKEND,HOLIDAY"
```


path: scripts/dida365.py
content: #!/usr/bin/env python3
"""CLI entrypoint for the Dida365 OpenAPI public skill."""

from __future__ import annotations

import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from dida365_lib.cli import main


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/dida365_lib/__init__.py
content: """Dida365 OpenAPI helper library for the public skill CLI."""



path: scripts/dida365_lib/auth.py
content: """OAuth helpers for the Dida365 OpenAPI CLI."""

from __future__ import annotations

import base64
import secrets
import threading
import time
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any, Optional
from urllib.parse import parse_qs, urlencode, urlparse

from .config import RuntimeSettings
from .errors import UserFacingError
from .http import HttpTransport


@dataclass
class CallbackResult:
    code: str
    state: Optional[str]
    query: dict[str, list[str]]


def build_authorize_url(settings: RuntimeSettings, state: Optional[str] = None) -> str:
    if not settings.client_id:
        raise UserFacingError("client_id is required to build the authorization URL")
    if not settings.redirect_uri:
        raise UserFacingError("redirect_uri is required to build the authorization URL")

    query = {
        "client_id": settings.client_id,
        "scope": settings.scope,
        "state": state or "",
        "redirect_uri": settings.redirect_uri,
        "response_type": "code",
    }
    base = settings.auth_base_url.rstrip("/")
    return f"{base}/oauth/authorize?{urlencode(query)}"


def exchange_code(
    settings: RuntimeSettings,
    *,
    code: str,
    transport: Optional[HttpTransport] = None,
) -> dict[str, Any]:
    if not settings.client_id:
        raise UserFacingError("client_id is required to exchange an authorization code")
    if not settings.client_secret:
        raise UserFacingError("client_secret is required to exchange an authorization code")
    if not settings.redirect_uri:
        raise UserFacingError("redirect_uri is required to exchange an authorization code")

    basic_auth = base64.b64encode(f"{settings.client_id}:{settings.client_secret}".encode("utf-8")).decode("ascii")
    client = transport or HttpTransport()
    response = client.request(
        "POST",
        f"{settings.auth_base_url.rstrip('/')}/oauth/token",
        headers={"Authorization": f"Basic {basic_auth}"},
        form_body={
            "code": code,
            "grant_type": "authorization_code",
            "scope": settings.scope,
            "redirect_uri": settings.redirect_uri,
        },
        timeout=settings.timeout,
    )
    if not isinstance(response.data, dict):
        raise UserFacingError("Token endpoint did not return a JSON object", exit_code=1)
    return response.data


def generate_state_token() -> str:
    return secrets.token_urlsafe(24)


def derive_local_redirect_uri(redirect_uri: Optional[str]) -> tuple[str, int, str, str]:
    candidate = redirect_uri or "http://127.0.0.1:36500/callback"
    parsed = urlparse(candidate)
    if parsed.scheme != "http":
        raise UserFacingError("Local OAuth redirect_uri must use http://", exit_code=2)
    if parsed.hostname not in {"127.0.0.1", "localhost"}:
        raise UserFacingError(
            "Local OAuth redirect_uri must point to 127.0.0.1 or localhost",
            details={"redirect_uri": candidate},
            exit_code=2,
        )
    port = parsed.port or 80
    path = parsed.path or "/"
    normalized = f"http://{parsed.hostname}:{port}{path}"
    return parsed.hostname, port, path, normalized


def wait_for_local_callback(
    *,
    host: str,
    port: int,
    path: str,
    timeout: float,
) -> CallbackResult:
    result: dict[str, Any] = {}
    event = threading.Event()

    class CallbackHandler(BaseHTTPRequestHandler):
        def do_GET(self) -> None:  # noqa: N802
            parsed = urlparse(self.path)
            if parsed.path != path:
                self.send_response(404)
                self.send_header("Content-Type", "text/plain; charset=utf-8")
                self.end_headers()
                self.wfile.write(b"Not found")
                return

            query = parse_qs(parsed.query)
            code = query.get("code", [None])[0]
            state = query.get("state", [None])[0]

            if not code:
                self.send_response(400)
                self.send_header("Content-Type", "text/plain; charset=utf-8")
                self.end_headers()
                self.wfile.write(b"Missing code query parameter")
                return

            result["code"] = code
            result["state"] = state
            result["query"] = query
            event.set()

            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write(
                b"<html><body><h1>Authorization complete</h1><p>You can close this tab and return to your terminal.</p></body></html>"
            )

        def log_message(self, format: str, *args: Any) -> None:  # noqa: A003
            return

    class ReusableHTTPServer(HTTPServer):
        allow_reuse_address = True

    try:
        server = ReusableHTTPServer((host, port), CallbackHandler)
    except OSError as exc:
        raise UserFacingError(
            "Unable to listen for the local OAuth callback",
            details={"host": host, "port": port, "reason": str(exc)},
            exit_code=1,
        ) from exc

    server.timeout = 0.5

    def serve() -> None:
        while not event.is_set():
            server.handle_request()

    thread = threading.Thread(target=serve, daemon=True)
    thread.start()
    deadline = time.time() + timeout
    try:
        while time.time() < deadline:
            if event.wait(timeout=0.2):
                return CallbackResult(
                    code=result["code"],
                    state=result.get("state"),
                    query=result.get("query", {}),
                )
        raise UserFacingError(
            "Timed out waiting for the OAuth callback",
            details={"host": host, "port": port, "path": path, "timeout_seconds": timeout},
            exit_code=1,
        )
    finally:
        event.set()
        server.server_close()
        thread.join(timeout=1)


path: scripts/dida365_lib/client.py
content: """Dida365 OpenAPI client helpers."""

from __future__ import annotations

from typing import Any, Optional
from urllib.parse import quote

from .config import RuntimeSettings
from .errors import UserFacingError
from .http import HttpResponse, HttpTransport, UNSET


class Dida365Client:
    """Minimal Dida365 OpenAPI client with exact endpoint mappings."""

    def __init__(
        self,
        settings: RuntimeSettings,
        *,
        transport: Optional[HttpTransport] = None,
    ) -> None:
        self.settings = settings
        self.transport = transport or HttpTransport()
        self._project_cache: Optional[list[dict[str, Any]]] = None
        self._inbox_project_id_cache: Optional[str] = None

    def list_projects(self) -> HttpResponse:
        return self._request("GET", "/open/v1/project")

    def get_project(self, project_id: str) -> HttpResponse:
        return self._request("GET", f"/open/v1/project/{self._segment(project_id)}")

    def get_project_data(self, project_id: str) -> HttpResponse:
        return self._request("GET", f"/open/v1/project/{self._segment(project_id)}/data")

    def create_project(self, payload: dict[str, Any]) -> HttpResponse:
        response = self._request("POST", "/open/v1/project", json_body=payload)
        self._project_cache = None
        return response

    def update_project(self, project_id: str, payload: dict[str, Any]) -> HttpResponse:
        response = self._request("POST", f"/open/v1/project/{self._segment(project_id)}", json_body=payload)
        self._project_cache = None
        return response

    def delete_project(self, project_id: str) -> HttpResponse:
        response = self._request("DELETE", f"/open/v1/project/{self._segment(project_id)}")
        self._project_cache = None
        return response

    def get_task(self, project_id: str, task_id: str) -> HttpResponse:
        return self._request(
            "GET",
            f"/open/v1/project/{self._segment(project_id)}/task/{self._segment(task_id)}",
        )

    def create_task(self, payload: dict[str, Any]) -> HttpResponse:
        return self._request("POST", "/open/v1/task", json_body=payload)

    def update_task(self, task_id: str, payload: dict[str, Any]) -> HttpResponse:
        return self._request("POST", f"/open/v1/task/{self._segment(task_id)}", json_body=payload)

    def complete_task(self, project_id: str, task_id: str) -> HttpResponse:
        return self._request(
            "POST",
            f"/open/v1/project/{self._segment(project_id)}/task/{self._segment(task_id)}/complete",
        )

    def delete_task(self, project_id: str, task_id: str) -> HttpResponse:
        return self._request(
            "DELETE",
            f"/open/v1/project/{self._segment(project_id)}/task/{self._segment(task_id)}",
        )

    def move_task(self, payload: list[dict[str, Any]]) -> HttpResponse:
        return self._request("POST", "/open/v1/task/move", json_body=payload)

    def list_completed_tasks(self, payload: dict[str, Any]) -> HttpResponse:
        return self._request("POST", "/open/v1/task/completed", json_body=payload)

    def filter_tasks(self, payload: dict[str, Any]) -> HttpResponse:
        return self._request("POST", "/open/v1/task/filter", json_body=payload)

    def resolve_project_id(
        self,
        *,
        project_id: Optional[str] = None,
        project_name: Optional[str] = None,
    ) -> str:
        if project_id:
            return project_id
        if not project_name:
            raise UserFacingError("Either --project-id or --project-name is required")

        matches = []
        for item in self._project_items():
            if isinstance(item, dict) and item.get("name") == project_name:
                matches.append(item)

        if not matches:
            raise UserFacingError(
                "Project name did not match any project",
                details={"project_name": project_name},
            )
        if len(matches) > 1:
            raise UserFacingError(
                "Project name matched multiple projects",
                details={
                    "project_name": project_name,
                    "project_ids": [item.get("id") for item in matches],
                },
            )
        project_identifier = matches[0].get("id")
        if not project_identifier:
            raise UserFacingError(
                "Matched project did not contain an id",
                details={"project_name": project_name},
                exit_code=1,
            )
        return str(project_identifier)

    def resolve_concrete_inbox_project_id(self) -> str:
        if self._inbox_project_id_cache:
            return self._inbox_project_id_cache

        candidate = self._find_concrete_inbox_project_id()
        if not candidate:
            raise UserFacingError(
                "Could not determine the concrete inbox project id automatically",
                details={
                    "hint": (
                        "Literal projectId 'inbox' is not reliable for delete, move, completed, or filter. "
                        "Use a real inbox project id, or keep at least one inbox task available so it can be inferred."
                    )
                },
                exit_code=1,
            )

        self._inbox_project_id_cache = candidate
        return candidate

    def _request(
        self,
        method: str,
        path: str,
        *,
        json_body: Any = UNSET,
    ) -> HttpResponse:
        [REDACTED_SECRET]
        if not token:
            raise UserFacingError(
                "Access token is required. Run auth exchange-code or auth login-local first.",
            )
        return self.transport.request(
            method,
            f"{self.settings.api_base_url.rstrip('/')}{path}",
            headers={"Authorization": f"Bearer {token}"},
            json_body=json_body,
            timeout=self.settings.timeout,
        )

    def _segment(self, value: str) -> str:
        return quote(str(value), safe="")

    def _project_items(self) -> list[dict[str, Any]]:
        if self._project_cache is None:
            response = self.list_projects()
            if not isinstance(response.data, list):
                raise UserFacingError("Project list endpoint did not return a JSON array", exit_code=1)
            self._project_cache = [item for item in response.data if isinstance(item, dict)]
        return self._project_cache

    def _find_concrete_inbox_project_id(self) -> Optional[str]:
        response = self.get_project_data("inbox")
        candidate = self._extract_inbox_project_id(response.data)
        if candidate:
            return candidate

        response = self.filter_tasks({"status": [0]})
        candidate = self._extract_inbox_project_id(response.data)
        if candidate:
            return candidate

        response = self.list_completed_tasks({})
        return self._extract_inbox_project_id(response.data)

    def _extract_inbox_project_id(self, data: Any) -> Optional[str]:
        for candidate in self._iter_project_id_candidates(data):
            if candidate.startswith("inbox") and candidate != "inbox":
                return candidate
        return None

    def _iter_project_id_candidates(self, data: Any) -> list[str]:
        candidates: list[str] = []
        if isinstance(data, dict):
            project = data.get("project")
            if isinstance(project, dict):
                project_id = project.get("id")
                if project_id:
                    candidates.append(str(project_id))
            for key in ("tasks", "columns"):
                values = data.get(key)
                if isinstance(values, list):
                    for item in values:
                        if isinstance(item, dict):
                            project_id = item.get("projectId")
                            if project_id:
                                candidates.append(str(project_id))
        elif isinstance(data, list):
            for item in data:
                if isinstance(item, dict):
                    project_id = item.get("projectId")
                    if project_id:
                        candidates.append(str(project_id))
        return candidates


path: scripts/dida365_lib/common.py
content: """Shared helpers for the Dida365 OpenAPI CLI."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Iterable, Mapping, Optional, Type


def json_dumps(data: Any, *, sort_keys: bool = False) -> str:
    return json.dumps(data, ensure_ascii=False, indent=2, sort_keys=sort_keys)


def load_json_file(path: str | Path, expected_type: Optional[Type[Any]] = None) -> Any:
    file_path = Path(path).expanduser()
    try:
        content = file_path.read_text(encoding="utf-8")
    except FileNotFoundError as exc:
        raise ValueError(f"JSON file not found: {file_path}") from exc
    except OSError as exc:
        raise ValueError(f"Unable to read JSON file: {file_path}") from exc
    return parse_json_text(content, label=str(file_path), expected_type=expected_type)


def parse_json_text(
    text: str,
    *,
    label: str,
    expected_type: Optional[Type[Any]] = None,
) -> Any:
    try:
        data = json.loads(text)
    except json.JSONDecodeError as exc:
        raise ValueError(f"{label} contains invalid JSON: {exc}") from exc
    if expected_type is not None and not isinstance(data, expected_type):
        raise ValueError(f"{label} must be a JSON {expected_type.__name__}")
    return data


def parse_bool(value: str) -> bool:
    lowered = value.strip().lower()
    if lowered in {"1", "true", "yes", "y", "on"}:
        return True
    if lowered in {"0", "false", "no", "n", "off"}:
        return False
    raise ValueError(f"Invalid boolean value: {value}")


def omit_none(mapping: Mapping[str, Any]) -> dict[str, Any]:
    return {key: value for key, value in mapping.items() if value is not None}


def compact_list(values: Iterable[str]) -> list[str]:
    result: list[str] = []
    seen: set[str] = set()
    for value in values:
        if value not in seen:
            result.append(value)
            seen.add(value)
    return result


def merge_json_object(base: dict[str, Any], updates: Mapping[str, Any]) -> dict[str, Any]:
    merged = dict(base)
    merged.update(updates)
    return merged


def redact_value(value: Any) -> Any:
    if not isinstance(value, str):
        return value
    if not value:
        return value
    if len(value) <= 8:
        return "*" * len(value)
    return f"{value[:4]}...{value[-4:]}"


def redact_mapping(data: Mapping[str, Any], keys: Iterable[str]) -> dict[str, Any]:
    secret_keys = set(keys)
    redacted: dict[str, Any] = {}
    for key, value in data.items():
        if key in secret_keys:
            redacted[key] = redact_value(value)
        else:
            redacted[key] = value
    return redacted


path: scripts/dida365_lib/errors.py
content: """Error types for the Dida365 OpenAPI CLI."""

from __future__ import annotations

from typing import Any, Optional


class UserFacingError(Exception):
    """An error safe to render directly to the CLI user."""

    def __init__(
        self,
        message: str,
        *,
        details: Optional[dict[str, Any]] = None,
        exit_code: int = 2,
    ) -> None:
        super().__init__(message)
        self.message = message
        self.details = details or {}
        self.exit_code = exit_code

    def to_error_dict(self) -> dict[str, Any]:
        payload = {
            "type": self.__class__.__name__,
            "message": self.message,
        }
        if self.details:
            payload["details"] = self.details
        return payload


class ApiError(UserFacingError):
    """HTTP error returned by the Dida365 API."""

    def __init__(
        self,
        message: str,
        *,
        status_code: int,
        method: str,
        url: str,
        response_body: Any = None,
    ) -> None:
        details = {
            "status_code": status_code,
            "method": method,
            "url": url,
        }
        if response_body not in (None, ""):
            details["response_body"] = response_body
        super().__init__(message, details=details, exit_code=1)
        self.status_code = status_code
        self.method = method
        self.url = url
        self.response_body = response_body



Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)