@kaname-tasks/kaname 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -0
- package/dist/chunk-T7KF3F3M.js +382 -0
- package/dist/index.js +351 -0
- package/dist/mcp.js +288 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Kaname CLI
|
|
2
|
+
|
|
3
|
+
Command-line client for Kaname. See `docs/cli-plan.md` for the design.
|
|
4
|
+
|
|
5
|
+
The package ships two binaries: `kaname` (the CLI) and `kaname-mcp` (an MCP
|
|
6
|
+
server exposing the same operations as tools — see [MCP server](#mcp-server)).
|
|
7
|
+
|
|
8
|
+
## Install (from the monorepo)
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
cd cli
|
|
12
|
+
npm install
|
|
13
|
+
npm run build
|
|
14
|
+
npm link # makes `kaname` and `kaname-mcp` available on your PATH
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Login
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
kaname login # Google sign-in via your browser (default)
|
|
21
|
+
kaname login --password # email/password prompt
|
|
22
|
+
kaname login --api-url https://api.example.com # persist a backend URL
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Google login needs a "Desktop app" OAuth client from the same Google Cloud
|
|
26
|
+
project as the web app, provided via `KANAME_GOOGLE_CLIENT_ID` /
|
|
27
|
+
`KANAME_GOOGLE_CLIENT_SECRET` (or `googleClientId` / `googleClientSecret` in
|
|
28
|
+
`~/.config/kaname/config.json`). The backend must list that client id in
|
|
29
|
+
`GOOGLE_CLI_CLIENT_ID`.
|
|
30
|
+
|
|
31
|
+
Tokens are stored in `~/.config/kaname/credentials.json` (mode 0600) and
|
|
32
|
+
refresh automatically; you only re-login after 30+ days of inactivity.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
kaname add buy milk --list groceries --when today
|
|
38
|
+
kaname add call mom --when fri --due +7d --notes "she prefers evenings"
|
|
39
|
+
|
|
40
|
+
kaname ls # today view (default)
|
|
41
|
+
kaname ls --inbox # todos with no list
|
|
42
|
+
kaname ls --all
|
|
43
|
+
kaname ls --list groceries
|
|
44
|
+
kaname ls --smart errands
|
|
45
|
+
|
|
46
|
+
kaname show 1 # #-indexes refer to the last `kaname ls` output
|
|
47
|
+
kaname done 1 2 # complete several at once
|
|
48
|
+
kaname undone 2
|
|
49
|
+
kaname edit 1 --when tomorrow --list none
|
|
50
|
+
kaname rm 1 # asks for confirmation (or --force)
|
|
51
|
+
|
|
52
|
+
kaname subtask add 1 whole milk
|
|
53
|
+
kaname subtask done 1 1
|
|
54
|
+
kaname subtask rm 1 2
|
|
55
|
+
|
|
56
|
+
kaname list ls
|
|
57
|
+
kaname list add groceries --color '#22c55e'
|
|
58
|
+
kaname list rename groceries errands
|
|
59
|
+
kaname list rm errands
|
|
60
|
+
|
|
61
|
+
kaname whoami
|
|
62
|
+
kaname logout
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Dates accept `today`, `tomorrow`, weekday names (`fri`), offsets (`+3d`), ISO
|
|
66
|
+
(`2026-07-10`), and `none` to clear a date in `edit`.
|
|
67
|
+
|
|
68
|
+
Every read command takes `--json` (raw API response, for `jq`/scripts) and
|
|
69
|
+
`--plain` (tab-separated, no color). Exit codes: `1` for errors, `2` for
|
|
70
|
+
"not logged in / session expired".
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm run dev -- ls # run from source
|
|
76
|
+
npm run lint # typecheck
|
|
77
|
+
npm test # unit tests
|
|
78
|
+
npm run generate-types # regenerate src/api/types.ts from a running backend
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`src/api/types.ts` is generated from the backend OpenAPI spec and committed.
|
|
82
|
+
Regenerate it after changing any Pydantic schema (same rule as the frontend).
|
|
83
|
+
|
|
84
|
+
## MCP server
|
|
85
|
+
|
|
86
|
+
`kaname-mcp` is a [Model Context Protocol](https://modelcontextprotocol.io)
|
|
87
|
+
server that lets an AI assistant (Claude Desktop, Claude Code, …) read and
|
|
88
|
+
manage your kaname todos. It's a stdio server that reuses the CLI's stored
|
|
89
|
+
session — so **log in once with `kaname login`**, and the MCP server picks up
|
|
90
|
+
the same credentials (with automatic token refresh). No separate auth.
|
|
91
|
+
|
|
92
|
+
### Tools
|
|
93
|
+
|
|
94
|
+
Full parity with the CLI:
|
|
95
|
+
|
|
96
|
+
- Todos: `list_todos`, `get_todo`, `add_todo`, `update_todo`, `complete_todo`,
|
|
97
|
+
`reopen_todo`, `delete_todo`
|
|
98
|
+
- Subtasks: `add_subtask`, `update_subtask`, `delete_subtask`
|
|
99
|
+
- Lists: `list_lists`, `list_smart_lists`, `create_list`, `rename_list`,
|
|
100
|
+
`delete_list`
|
|
101
|
+
|
|
102
|
+
Todos and subtasks are addressed by id (uuid); `list_todos` returns those ids.
|
|
103
|
+
Lists accept either a name or an id.
|
|
104
|
+
|
|
105
|
+
### Connect it
|
|
106
|
+
|
|
107
|
+
**Claude Code:**
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
claude mcp add kaname -- kaname-mcp
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Claude Desktop** (`claude_desktop_config.json`):
|
|
114
|
+
|
|
115
|
+
```jsonc
|
|
116
|
+
{
|
|
117
|
+
"mcpServers": {
|
|
118
|
+
"kaname": { "command": "kaname-mcp" }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
If `kaname-mcp` isn't on the client's PATH (GUI apps often have a minimal one),
|
|
124
|
+
use an absolute path — `which kaname-mcp` to find it — or point at the built
|
|
125
|
+
file: `"command": "node", "args": ["/abs/path/to/cli/dist/mcp.js"]`.
|
|
126
|
+
|
|
127
|
+
To target a non-default backend, set `KANAME_API_URL` (or persist it via
|
|
128
|
+
`kaname login --api-url …` before connecting).
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/config.ts
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import path from "path";
|
|
7
|
+
function configDir() {
|
|
8
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
9
|
+
return path.join(base, "kaname");
|
|
10
|
+
}
|
|
11
|
+
function cacheDir() {
|
|
12
|
+
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
|
|
13
|
+
return path.join(base, "kaname");
|
|
14
|
+
}
|
|
15
|
+
function readJson(file) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function writeJson(file, data, mode) {
|
|
23
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
24
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n", { mode });
|
|
25
|
+
if (mode !== void 0) {
|
|
26
|
+
fs.chmodSync(file, mode);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
var configFile = () => path.join(configDir(), "config.json");
|
|
30
|
+
var credentialsFile = () => path.join(configDir(), "credentials.json");
|
|
31
|
+
var lastViewFile = () => path.join(cacheDir(), "last-view.json");
|
|
32
|
+
function loadConfig() {
|
|
33
|
+
return readJson(configFile()) ?? {};
|
|
34
|
+
}
|
|
35
|
+
function saveConfig(config) {
|
|
36
|
+
writeJson(configFile(), config);
|
|
37
|
+
}
|
|
38
|
+
var DEFAULT_API_URL = "https://kaname-api-p0su.onrender.com";
|
|
39
|
+
function apiUrl() {
|
|
40
|
+
return process.env.KANAME_API_URL || loadConfig().apiUrl || DEFAULT_API_URL;
|
|
41
|
+
}
|
|
42
|
+
function loadCredentials() {
|
|
43
|
+
return readJson(credentialsFile());
|
|
44
|
+
}
|
|
45
|
+
function saveCredentials(creds) {
|
|
46
|
+
writeJson(credentialsFile(), creds, 384);
|
|
47
|
+
}
|
|
48
|
+
function clearCredentials() {
|
|
49
|
+
try {
|
|
50
|
+
fs.unlinkSync(credentialsFile());
|
|
51
|
+
} catch {
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function saveLastView(ids) {
|
|
55
|
+
writeJson(lastViewFile(), { ids });
|
|
56
|
+
}
|
|
57
|
+
function loadLastView() {
|
|
58
|
+
return readJson(lastViewFile())?.ids ?? [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/api/client.ts
|
|
62
|
+
var ApiError = class extends Error {
|
|
63
|
+
constructor(message, status) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.status = status;
|
|
66
|
+
}
|
|
67
|
+
status;
|
|
68
|
+
};
|
|
69
|
+
var AuthError = class extends Error {
|
|
70
|
+
};
|
|
71
|
+
var SKIP_REFRESH = ["/auth/login", "/auth/register", "/auth/refresh", "/auth/google", "/auth/apple", "/auth/2fa"];
|
|
72
|
+
var refreshPromise = null;
|
|
73
|
+
async function refreshTokens() {
|
|
74
|
+
if (refreshPromise) return refreshPromise;
|
|
75
|
+
const creds = loadCredentials();
|
|
76
|
+
if (!creds) return false;
|
|
77
|
+
refreshPromise = (async () => {
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(`${apiUrl()}/auth/refresh`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "Content-Type": "application/json" },
|
|
82
|
+
body: JSON.stringify({ refresh_token: creds.refreshToken })
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) return false;
|
|
85
|
+
const data = await res.json();
|
|
86
|
+
saveCredentials({ accessToken: data.access_token, refreshToken: data.refresh_token });
|
|
87
|
+
return true;
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
} finally {
|
|
91
|
+
refreshPromise = null;
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
return refreshPromise;
|
|
95
|
+
}
|
|
96
|
+
async function request(method, endpoint, body, isRetry = false) {
|
|
97
|
+
const headers = { "Content-Type": "application/json" };
|
|
98
|
+
const creds = loadCredentials();
|
|
99
|
+
if (creds) {
|
|
100
|
+
headers["Authorization"] = `Bearer ${creds.accessToken}`;
|
|
101
|
+
} else if (!SKIP_REFRESH.some((e) => endpoint.startsWith(e))) {
|
|
102
|
+
throw new AuthError("Not logged in. Run `kaname login`.");
|
|
103
|
+
}
|
|
104
|
+
let res;
|
|
105
|
+
try {
|
|
106
|
+
res = await fetch(`${apiUrl()}${endpoint}`, {
|
|
107
|
+
method,
|
|
108
|
+
headers,
|
|
109
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
110
|
+
});
|
|
111
|
+
} catch (err) {
|
|
112
|
+
throw new ApiError(`Could not reach ${apiUrl()} (${err.message})`, 0);
|
|
113
|
+
}
|
|
114
|
+
if (res.status === 204) return void 0;
|
|
115
|
+
const shouldSkipRefresh = SKIP_REFRESH.some((e) => endpoint.startsWith(e));
|
|
116
|
+
if (res.status === 401 && !isRetry && !shouldSkipRefresh) {
|
|
117
|
+
if (await refreshTokens()) {
|
|
118
|
+
return request(method, endpoint, body, true);
|
|
119
|
+
}
|
|
120
|
+
clearCredentials();
|
|
121
|
+
throw new AuthError("Session expired. Run `kaname login`.");
|
|
122
|
+
}
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
let detail = `HTTP ${res.status}`;
|
|
125
|
+
try {
|
|
126
|
+
const data = await res.json();
|
|
127
|
+
if (typeof data.detail === "string") detail = data.detail;
|
|
128
|
+
else if (data.detail) detail = JSON.stringify(data.detail);
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
throw new ApiError(detail, res.status);
|
|
132
|
+
}
|
|
133
|
+
return await res.json();
|
|
134
|
+
}
|
|
135
|
+
var api = {
|
|
136
|
+
get: (endpoint) => request("GET", endpoint),
|
|
137
|
+
post: (endpoint, body) => request("POST", endpoint, body),
|
|
138
|
+
put: (endpoint, body) => request("PUT", endpoint, body),
|
|
139
|
+
del: (endpoint) => request("DELETE", endpoint)
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// src/util/dates.ts
|
|
143
|
+
var WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
|
144
|
+
function toIso(d) {
|
|
145
|
+
const y = d.getFullYear();
|
|
146
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
147
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
148
|
+
return `${y}-${m}-${day}`;
|
|
149
|
+
}
|
|
150
|
+
function localToday(now = /* @__PURE__ */ new Date()) {
|
|
151
|
+
return toIso(now);
|
|
152
|
+
}
|
|
153
|
+
function parseDateArg(input, now = /* @__PURE__ */ new Date()) {
|
|
154
|
+
const s = input.trim().toLowerCase();
|
|
155
|
+
if (s === "none" || s === "clear") return null;
|
|
156
|
+
if (s === "today" || s === "tod") return toIso(now);
|
|
157
|
+
if (s === "tomorrow" || s === "tom") {
|
|
158
|
+
const d = new Date(now);
|
|
159
|
+
d.setDate(d.getDate() + 1);
|
|
160
|
+
return toIso(d);
|
|
161
|
+
}
|
|
162
|
+
const offset = s.match(/^\+?(\d{1,3})d$/);
|
|
163
|
+
if (offset) {
|
|
164
|
+
const d = new Date(now);
|
|
165
|
+
d.setDate(d.getDate() + Number(offset[1]));
|
|
166
|
+
return toIso(d);
|
|
167
|
+
}
|
|
168
|
+
const weekdayIndex = WEEKDAYS.findIndex((w) => w === s || w.slice(0, 3) === s);
|
|
169
|
+
if (weekdayIndex >= 0) {
|
|
170
|
+
const d = new Date(now);
|
|
171
|
+
d.setDate(d.getDate() + (weekdayIndex - d.getDay() + 7) % 7);
|
|
172
|
+
return toIso(d);
|
|
173
|
+
}
|
|
174
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
|
175
|
+
const parsed = /* @__PURE__ */ new Date(`${s}T00:00:00`);
|
|
176
|
+
if (Number.isNaN(parsed.getTime())) throw new Error(`Invalid date: ${input}`);
|
|
177
|
+
return s;
|
|
178
|
+
}
|
|
179
|
+
throw new Error(`Cannot parse date "${input}" (try: today, tomorrow, fri, +3d, 2026-07-10)`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/commands/lists.ts
|
|
183
|
+
import pc2 from "picocolors";
|
|
184
|
+
|
|
185
|
+
// src/util/confirm.ts
|
|
186
|
+
import * as p from "@clack/prompts";
|
|
187
|
+
async function confirmOrFail(message) {
|
|
188
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
189
|
+
throw new Error("Refusing to prompt in non-interactive mode \u2014 pass --force to skip confirmation.");
|
|
190
|
+
}
|
|
191
|
+
const ok = await p.confirm({ message, initialValue: false });
|
|
192
|
+
return !p.isCancel(ok) && ok === true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/util/output.ts
|
|
196
|
+
import pc from "picocolors";
|
|
197
|
+
function printJson(data) {
|
|
198
|
+
console.log(JSON.stringify(data, null, 2));
|
|
199
|
+
}
|
|
200
|
+
function dateLabel(iso, time) {
|
|
201
|
+
if (!iso) return "";
|
|
202
|
+
const today = localToday();
|
|
203
|
+
let label = iso;
|
|
204
|
+
if (iso === today) label = "today";
|
|
205
|
+
else {
|
|
206
|
+
const tomorrow = /* @__PURE__ */ new Date();
|
|
207
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
208
|
+
if (iso === localToday(tomorrow)) label = "tomorrow";
|
|
209
|
+
}
|
|
210
|
+
if (time) label += ` ${time.slice(0, 5)}`;
|
|
211
|
+
return label;
|
|
212
|
+
}
|
|
213
|
+
function colorDate(label, iso, completed) {
|
|
214
|
+
if (!label) return "";
|
|
215
|
+
if (!completed && iso && iso < localToday()) return pc.red(label);
|
|
216
|
+
if (label.startsWith("today")) return pc.yellow(label);
|
|
217
|
+
return pc.cyan(label);
|
|
218
|
+
}
|
|
219
|
+
function printTodos(todos, opts, listNames) {
|
|
220
|
+
const ids = todos.map((t) => t.id);
|
|
221
|
+
if (opts.json) {
|
|
222
|
+
printJson(todos);
|
|
223
|
+
return ids;
|
|
224
|
+
}
|
|
225
|
+
if (opts.plain) {
|
|
226
|
+
for (const t of todos) {
|
|
227
|
+
console.log([t.id, t.completed ? "done" : "open", t.title, t.when_date ?? "", t.due_date ?? ""].join(" "));
|
|
228
|
+
}
|
|
229
|
+
return ids;
|
|
230
|
+
}
|
|
231
|
+
if (todos.length === 0) {
|
|
232
|
+
console.log(pc.dim("No todos."));
|
|
233
|
+
return ids;
|
|
234
|
+
}
|
|
235
|
+
const indexWidth = String(todos.length).length + 1;
|
|
236
|
+
todos.forEach((t, i) => {
|
|
237
|
+
const index = pc.dim(`#${i + 1}`.padStart(indexWidth + 1));
|
|
238
|
+
const mark = t.completed ? pc.green("\u2713") : "\u25CB";
|
|
239
|
+
const title = t.completed ? pc.dim(t.title) : t.title;
|
|
240
|
+
const parts = [index, mark, title];
|
|
241
|
+
const when = colorDate(dateLabel(t.when_date, t.when_time), t.when_date, t.completed);
|
|
242
|
+
if (when) parts.push(when);
|
|
243
|
+
if (t.due_date) {
|
|
244
|
+
parts.push(pc.dim("due ") + colorDate(dateLabel(t.due_date, t.due_time), t.due_date, t.completed));
|
|
245
|
+
}
|
|
246
|
+
if (t.list_id && listNames?.has(t.list_id)) {
|
|
247
|
+
parts.push(pc.magenta(`[${listNames.get(t.list_id)}]`));
|
|
248
|
+
}
|
|
249
|
+
if (t.recurrence_rule) parts.push(pc.dim("\u21BB"));
|
|
250
|
+
console.log(parts.join(" "));
|
|
251
|
+
});
|
|
252
|
+
return ids;
|
|
253
|
+
}
|
|
254
|
+
function printTodoDetail(todo, opts) {
|
|
255
|
+
if (opts.json) {
|
|
256
|
+
printJson(todo);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
console.log(`${todo.completed ? pc.green("\u2713") : "\u25CB"} ${pc.bold(todo.title)}`);
|
|
260
|
+
console.log(pc.dim(` id: ${todo.id}`));
|
|
261
|
+
if (todo.description) console.log(` ${todo.description}`);
|
|
262
|
+
if (todo.when_date) console.log(` when: ${colorDate(dateLabel(todo.when_date, todo.when_time), todo.when_date, todo.completed)}`);
|
|
263
|
+
if (todo.due_date) console.log(` due: ${colorDate(dateLabel(todo.due_date, todo.due_time), todo.due_date, todo.completed)}`);
|
|
264
|
+
if (todo.recurrence_rule) console.log(pc.dim(` repeats: ${todo.recurrence_rule}`));
|
|
265
|
+
const subtasks = todo.subtasks ?? [];
|
|
266
|
+
if (subtasks.length > 0) {
|
|
267
|
+
console.log(" subtasks:");
|
|
268
|
+
subtasks.forEach((s, i) => {
|
|
269
|
+
console.log(` ${pc.dim(String(i + 1) + ".")} ${s.completed ? pc.green("\u2713") : "\u25CB"} ${s.completed ? pc.dim(s.title) : s.title}`);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// src/commands/lists.ts
|
|
275
|
+
function flattenLists(lists) {
|
|
276
|
+
const flat = [];
|
|
277
|
+
const walk = (nodes) => {
|
|
278
|
+
for (const node of nodes) {
|
|
279
|
+
flat.push(node);
|
|
280
|
+
if (node.children?.length) walk(node.children);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
walk(lists);
|
|
284
|
+
return flat;
|
|
285
|
+
}
|
|
286
|
+
async function fetchLists() {
|
|
287
|
+
return api.get("/lists");
|
|
288
|
+
}
|
|
289
|
+
function resolveList(lists, name) {
|
|
290
|
+
const flat = flattenLists(lists);
|
|
291
|
+
const lower = name.toLowerCase();
|
|
292
|
+
const exact = flat.filter((l) => l.name.toLowerCase() === lower);
|
|
293
|
+
if (exact.length === 1) return exact[0];
|
|
294
|
+
if (exact.length > 1) throw new Error(`Multiple lists named "${name}" \u2014 use the list id.`);
|
|
295
|
+
const prefix = flat.filter((l) => l.name.toLowerCase().startsWith(lower));
|
|
296
|
+
if (prefix.length === 1) return prefix[0];
|
|
297
|
+
if (prefix.length > 1) {
|
|
298
|
+
throw new Error(`Ambiguous list "${name}": ${prefix.map((l) => l.name).join(", ")}`);
|
|
299
|
+
}
|
|
300
|
+
throw new Error(`No list named "${name}". Run \`kaname list ls\`.`);
|
|
301
|
+
}
|
|
302
|
+
async function listNameMap() {
|
|
303
|
+
try {
|
|
304
|
+
return new Map(flattenLists(await fetchLists()).map((l) => [l.id, l.name]));
|
|
305
|
+
} catch {
|
|
306
|
+
return /* @__PURE__ */ new Map();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function printTree(lists, opts) {
|
|
310
|
+
if (opts.json) {
|
|
311
|
+
printJson(lists);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (lists.length === 0) {
|
|
315
|
+
console.log(pc2.dim("No lists."));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const walk = (nodes, depth) => {
|
|
319
|
+
for (const node of nodes) {
|
|
320
|
+
const indent = " ".repeat(depth);
|
|
321
|
+
const count = pc2.dim(`(${node.todo_count})`);
|
|
322
|
+
const shared = node.is_shared ? pc2.blue(node.is_owner ? " [shared]" : ` [via ${node.owner_email}]`) : "";
|
|
323
|
+
console.log(`${indent}${node.name} ${count}${shared}`);
|
|
324
|
+
if (node.children?.length) walk(node.children, depth + 1);
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
walk(lists, 0);
|
|
328
|
+
}
|
|
329
|
+
function registerListCommands(program) {
|
|
330
|
+
const list = program.command("list").description("Manage lists");
|
|
331
|
+
list.command("ls").description("Show all lists").action(async () => {
|
|
332
|
+
printTree(await fetchLists(), program.opts());
|
|
333
|
+
});
|
|
334
|
+
list.command("add <name>").description("Create a list").option("--parent <list>", "parent list name").option("--color <color>", "list color").action(async (name, opts) => {
|
|
335
|
+
let parentId;
|
|
336
|
+
if (opts.parent) parentId = resolveList(await fetchLists(), opts.parent).id;
|
|
337
|
+
const created = await api.post("/lists", {
|
|
338
|
+
name,
|
|
339
|
+
color: opts.color,
|
|
340
|
+
parent_id: parentId
|
|
341
|
+
});
|
|
342
|
+
console.log(pc2.green(`Created list "${created.name}"`));
|
|
343
|
+
});
|
|
344
|
+
list.command("rename <list> <newName>").description("Rename a list").action(async (listName, newName) => {
|
|
345
|
+
const target = resolveList(await fetchLists(), listName);
|
|
346
|
+
await api.put(`/lists/${target.id}`, { name: newName });
|
|
347
|
+
console.log(`Renamed "${target.name}" \u2192 "${newName}"`);
|
|
348
|
+
});
|
|
349
|
+
list.command("rm <list>").description("Delete a list (and its todos)").option("-f, --force", "skip confirmation").action(async (listName, opts) => {
|
|
350
|
+
const target = resolveList(await fetchLists(), listName);
|
|
351
|
+
if (!opts.force && !await confirmOrFail(`Delete list "${target.name}" (${target.todo_count} todos)?`)) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
await api.del(`/lists/${target.id}`);
|
|
355
|
+
console.log(`Deleted list "${target.name}"`);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export {
|
|
360
|
+
loadConfig,
|
|
361
|
+
saveConfig,
|
|
362
|
+
apiUrl,
|
|
363
|
+
loadCredentials,
|
|
364
|
+
saveCredentials,
|
|
365
|
+
clearCredentials,
|
|
366
|
+
saveLastView,
|
|
367
|
+
loadLastView,
|
|
368
|
+
ApiError,
|
|
369
|
+
AuthError,
|
|
370
|
+
api,
|
|
371
|
+
confirmOrFail,
|
|
372
|
+
localToday,
|
|
373
|
+
parseDateArg,
|
|
374
|
+
printJson,
|
|
375
|
+
printTodos,
|
|
376
|
+
printTodoDetail,
|
|
377
|
+
flattenLists,
|
|
378
|
+
fetchLists,
|
|
379
|
+
resolveList,
|
|
380
|
+
listNameMap,
|
|
381
|
+
registerListCommands
|
|
382
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ApiError,
|
|
4
|
+
AuthError,
|
|
5
|
+
api,
|
|
6
|
+
apiUrl,
|
|
7
|
+
clearCredentials,
|
|
8
|
+
confirmOrFail,
|
|
9
|
+
fetchLists,
|
|
10
|
+
listNameMap,
|
|
11
|
+
loadConfig,
|
|
12
|
+
loadLastView,
|
|
13
|
+
localToday,
|
|
14
|
+
parseDateArg,
|
|
15
|
+
printJson,
|
|
16
|
+
printTodoDetail,
|
|
17
|
+
printTodos,
|
|
18
|
+
registerListCommands,
|
|
19
|
+
resolveList,
|
|
20
|
+
saveConfig,
|
|
21
|
+
saveCredentials,
|
|
22
|
+
saveLastView
|
|
23
|
+
} from "./chunk-T7KF3F3M.js";
|
|
24
|
+
|
|
25
|
+
// src/index.ts
|
|
26
|
+
import { Command } from "commander";
|
|
27
|
+
import pc3 from "picocolors";
|
|
28
|
+
|
|
29
|
+
// src/commands/auth.ts
|
|
30
|
+
import crypto from "crypto";
|
|
31
|
+
import http from "http";
|
|
32
|
+
import { spawn } from "child_process";
|
|
33
|
+
import * as p from "@clack/prompts";
|
|
34
|
+
import pc from "picocolors";
|
|
35
|
+
function saveTokenPair(token) {
|
|
36
|
+
saveCredentials({ accessToken: token.access_token, refreshToken: token.refresh_token });
|
|
37
|
+
}
|
|
38
|
+
function openBrowser(url) {
|
|
39
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
40
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
41
|
+
try {
|
|
42
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
43
|
+
} catch {
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function googleOAuthConfig() {
|
|
47
|
+
const config = loadConfig();
|
|
48
|
+
const clientId = process.env.KANAME_GOOGLE_CLIENT_ID || config.googleClientId;
|
|
49
|
+
const clientSecret = process.env.KANAME_GOOGLE_CLIENT_SECRET || config.googleClientSecret;
|
|
50
|
+
if (!clientId || !clientSecret) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
'Google OAuth is not configured. Set KANAME_GOOGLE_CLIENT_ID / KANAME_GOOGLE_CLIENT_SECRET (a "Desktop app" OAuth client), or use `kaname login --password`.'
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return { clientId, clientSecret };
|
|
56
|
+
}
|
|
57
|
+
async function googleIdToken({ clientId, clientSecret }) {
|
|
58
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
59
|
+
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
60
|
+
const state = crypto.randomBytes(16).toString("base64url");
|
|
61
|
+
let redirectUri = "";
|
|
62
|
+
const code = await new Promise((resolve, reject) => {
|
|
63
|
+
const server = http.createServer((req, res2) => {
|
|
64
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
65
|
+
if (url.pathname !== "/callback") {
|
|
66
|
+
res2.writeHead(404).end();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
res2.writeHead(200, { "Content-Type": "text/html" });
|
|
70
|
+
res2.end("<html><body><p>You are logged in to the Kaname CLI. You can close this tab.</p></body></html>");
|
|
71
|
+
server.close();
|
|
72
|
+
const err = url.searchParams.get("error");
|
|
73
|
+
if (err) return reject(new Error(`Google login failed: ${err}`));
|
|
74
|
+
if (url.searchParams.get("state") !== state) return reject(new Error("OAuth state mismatch."));
|
|
75
|
+
const c = url.searchParams.get("code");
|
|
76
|
+
if (!c) return reject(new Error("No authorization code in callback."));
|
|
77
|
+
resolve(c);
|
|
78
|
+
});
|
|
79
|
+
server.on("error", reject);
|
|
80
|
+
server.listen(0, "127.0.0.1", () => {
|
|
81
|
+
const address = server.address();
|
|
82
|
+
if (address === null || typeof address === "string") return reject(new Error("Could not bind loopback port."));
|
|
83
|
+
redirectUri = `http://127.0.0.1:${address.port}/callback`;
|
|
84
|
+
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
|
|
85
|
+
authUrl.search = new URLSearchParams({
|
|
86
|
+
client_id: clientId,
|
|
87
|
+
redirect_uri: redirectUri,
|
|
88
|
+
response_type: "code",
|
|
89
|
+
scope: "openid email",
|
|
90
|
+
code_challenge: challenge,
|
|
91
|
+
code_challenge_method: "S256",
|
|
92
|
+
state
|
|
93
|
+
}).toString();
|
|
94
|
+
console.log("Opening your browser for Google sign-in\u2026");
|
|
95
|
+
console.log(pc.dim(`If it doesn't open, visit:
|
|
96
|
+
${authUrl}`));
|
|
97
|
+
openBrowser(authUrl.toString());
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
const res = await fetch("https://oauth2.googleapis.com/token", {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
103
|
+
body: new URLSearchParams({
|
|
104
|
+
code,
|
|
105
|
+
client_id: clientId,
|
|
106
|
+
client_secret: clientSecret,
|
|
107
|
+
redirect_uri: redirectUri,
|
|
108
|
+
grant_type: "authorization_code",
|
|
109
|
+
code_verifier: verifier
|
|
110
|
+
})
|
|
111
|
+
});
|
|
112
|
+
const data = await res.json();
|
|
113
|
+
if (!res.ok || !data.id_token) {
|
|
114
|
+
throw new Error(`Google token exchange failed: ${data.error_description || data.error || res.status}`);
|
|
115
|
+
}
|
|
116
|
+
return data.id_token;
|
|
117
|
+
}
|
|
118
|
+
async function passwordLogin() {
|
|
119
|
+
const email = await p.text({ message: "Email", validate: (v) => v.includes("@") ? void 0 : "Enter an email" });
|
|
120
|
+
if (p.isCancel(email)) process.exit(130);
|
|
121
|
+
const password2 = await p.password({ message: "Password" });
|
|
122
|
+
if (p.isCancel(password2)) process.exit(130);
|
|
123
|
+
const result = await api.post("/auth/login", {
|
|
124
|
+
email,
|
|
125
|
+
password: password2
|
|
126
|
+
});
|
|
127
|
+
if ("pending_2fa_token" in result) {
|
|
128
|
+
await twoFactorLogin(result.pending_2fa_token);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
saveTokenPair(result);
|
|
132
|
+
}
|
|
133
|
+
async function twoFactorLogin(pendingToken) {
|
|
134
|
+
const authHeaders = { Authorization: `Bearer ${pendingToken}`, "Content-Type": "application/json" };
|
|
135
|
+
const sendRes = await fetch(`${apiUrl()}/auth/2fa/send-otp`, { method: "POST", headers: authHeaders });
|
|
136
|
+
if (!sendRes.ok) throw new ApiError("Failed to send verification code.", sendRes.status);
|
|
137
|
+
console.log("A verification code was sent to your email.");
|
|
138
|
+
const code = await p.text({ message: "Verification code" });
|
|
139
|
+
if (p.isCancel(code)) process.exit(130);
|
|
140
|
+
const verifyRes = await fetch(`${apiUrl()}/auth/2fa/verify-otp`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: authHeaders,
|
|
143
|
+
body: JSON.stringify({ code })
|
|
144
|
+
});
|
|
145
|
+
if (!verifyRes.ok) throw new ApiError("Invalid verification code.", verifyRes.status);
|
|
146
|
+
const token = await verifyRes.json();
|
|
147
|
+
saveTokenPair(token);
|
|
148
|
+
}
|
|
149
|
+
function registerAuthCommands(program2) {
|
|
150
|
+
program2.command("login").description("Log in with Google (default) or email/password").option("--password", "log in with email and password instead of Google").option("--api-url <url>", "backend URL to use (persisted to config)").action(async (opts) => {
|
|
151
|
+
if (opts.apiUrl) {
|
|
152
|
+
saveConfig({ ...loadConfig(), apiUrl: opts.apiUrl });
|
|
153
|
+
console.log(`API URL set to ${opts.apiUrl}`);
|
|
154
|
+
}
|
|
155
|
+
if (opts.password) {
|
|
156
|
+
await passwordLogin();
|
|
157
|
+
} else {
|
|
158
|
+
const idToken = await googleIdToken(googleOAuthConfig());
|
|
159
|
+
const token = await api.post("/auth/google", { credential: idToken });
|
|
160
|
+
saveTokenPair(token);
|
|
161
|
+
}
|
|
162
|
+
const me = await api.get("/auth/me");
|
|
163
|
+
console.log(pc.green(`Logged in as ${me.email}`));
|
|
164
|
+
});
|
|
165
|
+
program2.command("logout").description("Remove stored credentials").action(() => {
|
|
166
|
+
clearCredentials();
|
|
167
|
+
console.log("Logged out.");
|
|
168
|
+
});
|
|
169
|
+
program2.command("whoami").description("Show the logged-in account").action(async () => {
|
|
170
|
+
try {
|
|
171
|
+
const me = await api.get("/auth/me");
|
|
172
|
+
console.log(`${me.email} ${pc.dim(`(${me.id}, ${apiUrl()})`)}`);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (err instanceof AuthError) {
|
|
175
|
+
console.log(`Not logged in ${pc.dim(`(${apiUrl()})`)}`);
|
|
176
|
+
process.exitCode = 2;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/commands/todos.ts
|
|
185
|
+
import pc2 from "picocolors";
|
|
186
|
+
|
|
187
|
+
// src/util/ids.ts
|
|
188
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
189
|
+
function resolveTodoId(token) {
|
|
190
|
+
const indexMatch = token.match(/^#?(\d{1,4})$/);
|
|
191
|
+
if (indexMatch) {
|
|
192
|
+
const ids = loadLastView();
|
|
193
|
+
const index = Number(indexMatch[1]);
|
|
194
|
+
if (index < 1 || index > ids.length) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
ids.length === 0 ? `No cached listing \u2014 run \`kaname ls\` first, or pass a UUID.` : `#${index} is out of range (last listing had ${ids.length} items).`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return ids[index - 1];
|
|
200
|
+
}
|
|
201
|
+
if (UUID_RE.test(token)) return token;
|
|
202
|
+
if (/^[0-9a-f]{4,}$/i.test(token)) {
|
|
203
|
+
const matches = loadLastView().filter((id) => id.toLowerCase().startsWith(token.toLowerCase()));
|
|
204
|
+
if (matches.length === 1) return matches[0];
|
|
205
|
+
if (matches.length > 1) throw new Error(`Ambiguous id prefix "${token}" (${matches.length} matches).`);
|
|
206
|
+
throw new Error(`No todo in the last listing matches "${token}" \u2014 run \`kaname ls\` or pass a full UUID.`);
|
|
207
|
+
}
|
|
208
|
+
throw new Error(`"${token}" is not a #index, UUID, or UUID prefix.`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/commands/todos.ts
|
|
212
|
+
async function buildEditPayload(opts) {
|
|
213
|
+
const payload = {};
|
|
214
|
+
if (opts.title !== void 0) payload.title = opts.title;
|
|
215
|
+
if (opts.notes !== void 0) payload.description = opts.notes;
|
|
216
|
+
if (opts.when !== void 0) payload.when_date = parseDateArg(opts.when);
|
|
217
|
+
if (opts.due !== void 0) payload.due_date = parseDateArg(opts.due);
|
|
218
|
+
if (opts.list !== void 0) {
|
|
219
|
+
payload.list_id = opts.list === "none" ? null : resolveList(await fetchLists(), opts.list).id;
|
|
220
|
+
}
|
|
221
|
+
return payload;
|
|
222
|
+
}
|
|
223
|
+
function registerTodoCommands(program2) {
|
|
224
|
+
program2.command("add <title...>").description("Add a todo").option("-l, --list <list>", "list name").option("-w, --when <date>", "when to do it (today, tomorrow, fri, +3d, YYYY-MM-DD)").option("-d, --due <date>", "deadline").option("-n, --notes <text>", "description").action(async (titleWords, opts) => {
|
|
225
|
+
const payload = { title: titleWords.join(" ") };
|
|
226
|
+
if (opts.notes) payload.description = opts.notes;
|
|
227
|
+
if (opts.when) payload.when_date = parseDateArg(opts.when);
|
|
228
|
+
if (opts.due) payload.due_date = parseDateArg(opts.due);
|
|
229
|
+
if (opts.list) payload.list_id = resolveList(await fetchLists(), opts.list).id;
|
|
230
|
+
const todo = await api.post("/todos", payload);
|
|
231
|
+
const globalOpts = program2.opts();
|
|
232
|
+
if (globalOpts.json) printJson(todo);
|
|
233
|
+
else console.log(pc2.green("Added:") + ` ${todo.title} ${pc2.dim(todo.id)}`);
|
|
234
|
+
});
|
|
235
|
+
program2.command("ls").description("List todos (default: today view)").option("-t, --today", "todos scheduled for today (default)").option("-i, --inbox", "todos with no list").option("-a, --all", "all todos").option("-l, --list <list>", "todos in a list").option("-s, --smart <name>", "todos in a smart list").option("--status <status>", "filter by completion: open, done, or all", "all").action(
|
|
236
|
+
async (opts) => {
|
|
237
|
+
const status = (opts.status ?? "all").toLowerCase();
|
|
238
|
+
if (!["open", "done", "all"].includes(status)) {
|
|
239
|
+
throw new Error(`--status must be one of: open, done, all (got "${opts.status}").`);
|
|
240
|
+
}
|
|
241
|
+
const params = new URLSearchParams();
|
|
242
|
+
let annotateLists = true;
|
|
243
|
+
if (opts.all) {
|
|
244
|
+
params.set("all", "true");
|
|
245
|
+
} else if (opts.inbox) {
|
|
246
|
+
params.set("inbox", "true");
|
|
247
|
+
annotateLists = false;
|
|
248
|
+
} else if (opts.list) {
|
|
249
|
+
const target = resolveList(await fetchLists(), opts.list);
|
|
250
|
+
params.set("list_id", target.id);
|
|
251
|
+
annotateLists = false;
|
|
252
|
+
} else if (opts.smart) {
|
|
253
|
+
const smartLists = await api.get("/smart-lists");
|
|
254
|
+
const lower = opts.smart.toLowerCase();
|
|
255
|
+
const match = smartLists.filter((s) => s.name.toLowerCase().startsWith(lower));
|
|
256
|
+
if (match.length !== 1) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
match.length === 0 ? `No smart list matching "${opts.smart}".` : `Ambiguous smart list "${opts.smart}": ${match.map((s) => s.name).join(", ")}`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
params.set("smart_list_id", match[0].id);
|
|
262
|
+
} else {
|
|
263
|
+
params.set("today", "true");
|
|
264
|
+
}
|
|
265
|
+
params.set("client_date", localToday());
|
|
266
|
+
const todos = await api.get(`/todos?${params}`);
|
|
267
|
+
const filtered = status === "all" ? todos : todos.filter((t) => t.completed === (status === "done"));
|
|
268
|
+
const names = annotateLists ? await listNameMap() : void 0;
|
|
269
|
+
const ids = printTodos(filtered, program2.opts(), names);
|
|
270
|
+
saveLastView(ids);
|
|
271
|
+
}
|
|
272
|
+
);
|
|
273
|
+
program2.command("show <id>").description("Show a todo with its subtasks").action(async (idToken) => {
|
|
274
|
+
const todo = await api.get(`/todos/${resolveTodoId(idToken)}`);
|
|
275
|
+
printTodoDetail(todo, program2.opts());
|
|
276
|
+
});
|
|
277
|
+
program2.command("done <ids...>").description("Complete one or more todos").action(async (idTokens) => {
|
|
278
|
+
for (const token of idTokens) {
|
|
279
|
+
const todo = await api.put(`/todos/${resolveTodoId(token)}`, { completed: true });
|
|
280
|
+
console.log(`${pc2.green("\u2713")} ${todo.title}`);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
program2.command("undone <ids...>").description("Reopen one or more completed todos").action(async (idTokens) => {
|
|
284
|
+
for (const token of idTokens) {
|
|
285
|
+
const todo = await api.put(`/todos/${resolveTodoId(token)}`, { completed: false });
|
|
286
|
+
console.log(`\u25CB ${todo.title}`);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
program2.command("edit <id>").description("Edit a todo (only the fields you pass change)").option("--title <title>").option("-n, --notes <text>").option("-w, --when <date>", 'date, or "none" to clear').option("-d, --due <date>", 'date, or "none" to clear').option("-l, --list <list>", 'list name, or "none" for inbox').action(async (idToken, opts) => {
|
|
290
|
+
const payload = await buildEditPayload(opts);
|
|
291
|
+
if (Object.keys(payload).length === 0) {
|
|
292
|
+
throw new Error("Nothing to change \u2014 pass at least one of --title/--notes/--when/--due/--list.");
|
|
293
|
+
}
|
|
294
|
+
const todo = await api.put(`/todos/${resolveTodoId(idToken)}`, payload);
|
|
295
|
+
printTodoDetail(todo, program2.opts());
|
|
296
|
+
});
|
|
297
|
+
program2.command("rm <id>").description("Delete a todo").option("-f, --force", "skip confirmation").action(async (idToken, opts) => {
|
|
298
|
+
const id = resolveTodoId(idToken);
|
|
299
|
+
const todo = await api.get(`/todos/${id}`);
|
|
300
|
+
if (!opts.force && !await confirmOrFail(`Delete "${todo.title}"?`)) return;
|
|
301
|
+
await api.del(`/todos/${id}`);
|
|
302
|
+
console.log(`Deleted "${todo.title}"`);
|
|
303
|
+
});
|
|
304
|
+
const subtask = program2.command("subtask").description("Manage subtasks of a todo");
|
|
305
|
+
subtask.command("add <id> <title...>").description("Add a subtask").action(async (idToken, titleWords) => {
|
|
306
|
+
const created = await api.post(
|
|
307
|
+
`/todos/${resolveTodoId(idToken)}/subtasks`,
|
|
308
|
+
{ title: titleWords.join(" ") }
|
|
309
|
+
);
|
|
310
|
+
console.log(pc2.green("Added subtask:") + ` ${created.title}`);
|
|
311
|
+
});
|
|
312
|
+
const resolveSubtask = async (idToken, indexArg) => {
|
|
313
|
+
const todoId = resolveTodoId(idToken);
|
|
314
|
+
const todo = await api.get(`/todos/${todoId}`);
|
|
315
|
+
const subtasks = todo.subtasks ?? [];
|
|
316
|
+
const index = Number(indexArg);
|
|
317
|
+
if (!Number.isInteger(index) || index < 1 || index > subtasks.length) {
|
|
318
|
+
throw new Error(`Subtask index must be 1\u2013${subtasks.length} (see \`kaname show ${idToken}\`).`);
|
|
319
|
+
}
|
|
320
|
+
return { todoId, subtask: subtasks[index - 1] };
|
|
321
|
+
};
|
|
322
|
+
subtask.command("done <id> <n>").description("Complete subtask #n of a todo").action(async (idToken, n) => {
|
|
323
|
+
const { todoId, subtask: target } = await resolveSubtask(idToken, n);
|
|
324
|
+
await api.put(`/todos/${todoId}/subtasks/${target.id}`, { completed: true });
|
|
325
|
+
console.log(`${pc2.green("\u2713")} ${target.title}`);
|
|
326
|
+
});
|
|
327
|
+
subtask.command("rm <id> <n>").description("Delete subtask #n of a todo").action(async (idToken, n) => {
|
|
328
|
+
const { todoId, subtask: target } = await resolveSubtask(idToken, n);
|
|
329
|
+
await api.del(`/todos/${todoId}/subtasks/${target.id}`);
|
|
330
|
+
console.log(`Deleted subtask "${target.title}"`);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// src/index.ts
|
|
335
|
+
var program = new Command();
|
|
336
|
+
program.name("kaname").description("Kaname from the command line").version("0.1.0").option("-j, --json", "output raw JSON (for scripting)").option("--plain", "plain tab-separated output (no color)");
|
|
337
|
+
registerAuthCommands(program);
|
|
338
|
+
registerTodoCommands(program);
|
|
339
|
+
registerListCommands(program);
|
|
340
|
+
program.parseAsync().catch((err) => {
|
|
341
|
+
if (err instanceof AuthError) {
|
|
342
|
+
console.error(pc3.red(err.message));
|
|
343
|
+
process.exit(2);
|
|
344
|
+
}
|
|
345
|
+
if (err instanceof ApiError || err instanceof Error) {
|
|
346
|
+
console.error(pc3.red(err.message));
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
console.error(pc3.red(String(err)));
|
|
350
|
+
process.exit(1);
|
|
351
|
+
});
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ApiError,
|
|
4
|
+
AuthError,
|
|
5
|
+
api,
|
|
6
|
+
fetchLists,
|
|
7
|
+
flattenLists,
|
|
8
|
+
loadCredentials,
|
|
9
|
+
localToday,
|
|
10
|
+
parseDateArg,
|
|
11
|
+
resolveList
|
|
12
|
+
} from "./chunk-T7KF3F3M.js";
|
|
13
|
+
|
|
14
|
+
// src/mcp.ts
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
function ok(data) {
|
|
19
|
+
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
20
|
+
return { content: [{ type: "text", text }] };
|
|
21
|
+
}
|
|
22
|
+
async function run(fn) {
|
|
23
|
+
try {
|
|
24
|
+
return ok(await fn());
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (err instanceof AuthError) {
|
|
27
|
+
return { content: [{ type: "text", text: "Not logged in. Run `kaname login` in a terminal." }], isError: true };
|
|
28
|
+
}
|
|
29
|
+
const msg = err instanceof ApiError ? `${err.message} (HTTP ${err.status})` : err.message;
|
|
30
|
+
return { content: [{ type: "text", text: msg }], isError: true };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function resolveListArg(nameOrId) {
|
|
34
|
+
const lists = await fetchLists();
|
|
35
|
+
const byId = flattenLists(lists).find((l) => l.id === nameOrId);
|
|
36
|
+
return byId ?? resolveList(lists, nameOrId);
|
|
37
|
+
}
|
|
38
|
+
var dateArg = z.string().describe('today, tomorrow, a weekday (fri), a relative offset (+3d), an ISO date (2026-07-10), or "none" to clear');
|
|
39
|
+
function buildServer() {
|
|
40
|
+
const server = new McpServer({ name: "kaname", version: "0.1.0" });
|
|
41
|
+
server.registerTool(
|
|
42
|
+
"list_todos",
|
|
43
|
+
{
|
|
44
|
+
title: "List todos",
|
|
45
|
+
description: 'List todos. Default view is "today". Use view "list" with `list`, or "smart" with `smart`. Filter by completion with `status`. Returns todo items including their ids for use with other tools.',
|
|
46
|
+
inputSchema: {
|
|
47
|
+
view: z.enum(["today", "inbox", "all", "list", "smart"]).default("today"),
|
|
48
|
+
list: z.string().optional().describe('list name or id (required when view="list")'),
|
|
49
|
+
smart: z.string().optional().describe('smart list name (required when view="smart")'),
|
|
50
|
+
status: z.enum(["open", "done", "all"]).default("all")
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
async ({ view, list, smart, status }) => run(async () => {
|
|
54
|
+
const params = new URLSearchParams();
|
|
55
|
+
if (view === "all") params.set("all", "true");
|
|
56
|
+
else if (view === "inbox") params.set("inbox", "true");
|
|
57
|
+
else if (view === "list") {
|
|
58
|
+
if (!list) throw new Error('view="list" requires `list`.');
|
|
59
|
+
params.set("list_id", (await resolveListArg(list)).id);
|
|
60
|
+
} else if (view === "smart") {
|
|
61
|
+
if (!smart) throw new Error('view="smart" requires `smart`.');
|
|
62
|
+
const smartLists = await api.get("/smart-lists");
|
|
63
|
+
const lower = smart.toLowerCase();
|
|
64
|
+
const match = smartLists.filter((s) => s.name.toLowerCase().startsWith(lower));
|
|
65
|
+
if (match.length !== 1) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
match.length === 0 ? `No smart list matching "${smart}".` : `Ambiguous smart list "${smart}": ${match.map((s) => s.name).join(", ")}`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
params.set("smart_list_id", match[0].id);
|
|
71
|
+
} else {
|
|
72
|
+
params.set("today", "true");
|
|
73
|
+
}
|
|
74
|
+
params.set("client_date", localToday());
|
|
75
|
+
const todos = await api.get(`/todos?${params}`);
|
|
76
|
+
return status === "all" ? todos : todos.filter((t) => t.completed === (status === "done"));
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
server.registerTool(
|
|
80
|
+
"get_todo",
|
|
81
|
+
{
|
|
82
|
+
title: "Get todo",
|
|
83
|
+
description: "Get a single todo by id, including its subtasks.",
|
|
84
|
+
inputSchema: { id: z.string().describe("todo id (uuid)") }
|
|
85
|
+
},
|
|
86
|
+
async ({ id }) => run(() => api.get(`/todos/${id}`))
|
|
87
|
+
);
|
|
88
|
+
server.registerTool(
|
|
89
|
+
"add_todo",
|
|
90
|
+
{
|
|
91
|
+
title: "Add todo",
|
|
92
|
+
description: "Create a new todo.",
|
|
93
|
+
inputSchema: {
|
|
94
|
+
title: z.string(),
|
|
95
|
+
notes: z.string().optional().describe("description / notes"),
|
|
96
|
+
when: dateArg.optional().describe("when to do it"),
|
|
97
|
+
due: dateArg.optional().describe("deadline"),
|
|
98
|
+
list: z.string().optional().describe("list name or id")
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
async ({ title, notes, when, due, list }) => run(async () => {
|
|
102
|
+
const payload = { title };
|
|
103
|
+
if (notes !== void 0) payload.description = notes;
|
|
104
|
+
if (when !== void 0) payload.when_date = parseDateArg(when);
|
|
105
|
+
if (due !== void 0) payload.due_date = parseDateArg(due);
|
|
106
|
+
if (list !== void 0) payload.list_id = (await resolveListArg(list)).id;
|
|
107
|
+
return api.post("/todos", payload);
|
|
108
|
+
})
|
|
109
|
+
);
|
|
110
|
+
server.registerTool(
|
|
111
|
+
"update_todo",
|
|
112
|
+
{
|
|
113
|
+
title: "Update todo",
|
|
114
|
+
description: 'Update fields of a todo. Only the fields you pass change. Pass "none" to when/due/list to clear them.',
|
|
115
|
+
inputSchema: {
|
|
116
|
+
id: z.string().describe("todo id (uuid)"),
|
|
117
|
+
title: z.string().optional(),
|
|
118
|
+
notes: z.string().optional(),
|
|
119
|
+
when: dateArg.optional(),
|
|
120
|
+
due: dateArg.optional(),
|
|
121
|
+
list: z.string().optional().describe('list name or id, or "none" for inbox')
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
async ({ id, title, notes, when, due, list }) => run(async () => {
|
|
125
|
+
const payload = {};
|
|
126
|
+
if (title !== void 0) payload.title = title;
|
|
127
|
+
if (notes !== void 0) payload.description = notes;
|
|
128
|
+
if (when !== void 0) payload.when_date = parseDateArg(when);
|
|
129
|
+
if (due !== void 0) payload.due_date = parseDateArg(due);
|
|
130
|
+
if (list !== void 0) payload.list_id = list.toLowerCase() === "none" ? null : (await resolveListArg(list)).id;
|
|
131
|
+
if (Object.keys(payload).length === 0) {
|
|
132
|
+
throw new Error("Nothing to change \u2014 pass at least one of title/notes/when/due/list.");
|
|
133
|
+
}
|
|
134
|
+
return api.put(`/todos/${id}`, payload);
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
server.registerTool(
|
|
138
|
+
"complete_todo",
|
|
139
|
+
{
|
|
140
|
+
title: "Complete todo",
|
|
141
|
+
description: "Mark a todo as done.",
|
|
142
|
+
inputSchema: { id: z.string().describe("todo id (uuid)") }
|
|
143
|
+
},
|
|
144
|
+
async ({ id }) => run(() => api.put(`/todos/${id}`, { completed: true }))
|
|
145
|
+
);
|
|
146
|
+
server.registerTool(
|
|
147
|
+
"reopen_todo",
|
|
148
|
+
{
|
|
149
|
+
title: "Reopen todo",
|
|
150
|
+
description: "Mark a completed todo as open again.",
|
|
151
|
+
inputSchema: { id: z.string().describe("todo id (uuid)") }
|
|
152
|
+
},
|
|
153
|
+
async ({ id }) => run(() => api.put(`/todos/${id}`, { completed: false }))
|
|
154
|
+
);
|
|
155
|
+
server.registerTool(
|
|
156
|
+
"delete_todo",
|
|
157
|
+
{
|
|
158
|
+
title: "Delete todo",
|
|
159
|
+
description: "Permanently delete a todo and its subtasks.",
|
|
160
|
+
inputSchema: { id: z.string().describe("todo id (uuid)") }
|
|
161
|
+
},
|
|
162
|
+
async ({ id }) => run(async () => {
|
|
163
|
+
await api.del(`/todos/${id}`);
|
|
164
|
+
return `Deleted todo ${id}.`;
|
|
165
|
+
})
|
|
166
|
+
);
|
|
167
|
+
server.registerTool(
|
|
168
|
+
"add_subtask",
|
|
169
|
+
{
|
|
170
|
+
title: "Add subtask",
|
|
171
|
+
description: "Add a subtask to a todo.",
|
|
172
|
+
inputSchema: { todo_id: z.string().describe("parent todo id (uuid)"), title: z.string() }
|
|
173
|
+
},
|
|
174
|
+
async ({ todo_id, title }) => run(() => api.post(`/todos/${todo_id}/subtasks`, { title }))
|
|
175
|
+
);
|
|
176
|
+
server.registerTool(
|
|
177
|
+
"update_subtask",
|
|
178
|
+
{
|
|
179
|
+
title: "Update subtask",
|
|
180
|
+
description: "Update a subtask (rename and/or set completion). Get subtask ids from get_todo.",
|
|
181
|
+
inputSchema: {
|
|
182
|
+
todo_id: z.string().describe("parent todo id (uuid)"),
|
|
183
|
+
subtask_id: z.string().describe("subtask id (uuid)"),
|
|
184
|
+
title: z.string().optional(),
|
|
185
|
+
completed: z.boolean().optional()
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
async ({ todo_id, subtask_id, title, completed }) => run(() => {
|
|
189
|
+
const payload = {};
|
|
190
|
+
if (title !== void 0) payload.title = title;
|
|
191
|
+
if (completed !== void 0) payload.completed = completed;
|
|
192
|
+
if (Object.keys(payload).length === 0) throw new Error("Pass title and/or completed.");
|
|
193
|
+
return api.put(`/todos/${todo_id}/subtasks/${subtask_id}`, payload);
|
|
194
|
+
})
|
|
195
|
+
);
|
|
196
|
+
server.registerTool(
|
|
197
|
+
"delete_subtask",
|
|
198
|
+
{
|
|
199
|
+
title: "Delete subtask",
|
|
200
|
+
description: "Delete a subtask from a todo.",
|
|
201
|
+
inputSchema: {
|
|
202
|
+
todo_id: z.string().describe("parent todo id (uuid)"),
|
|
203
|
+
subtask_id: z.string().describe("subtask id (uuid)")
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
async ({ todo_id, subtask_id }) => run(async () => {
|
|
207
|
+
await api.del(`/todos/${todo_id}/subtasks/${subtask_id}`);
|
|
208
|
+
return `Deleted subtask ${subtask_id}.`;
|
|
209
|
+
})
|
|
210
|
+
);
|
|
211
|
+
server.registerTool(
|
|
212
|
+
"list_lists",
|
|
213
|
+
{
|
|
214
|
+
title: "List lists",
|
|
215
|
+
description: "Show all lists as a tree, with todo counts.",
|
|
216
|
+
inputSchema: {}
|
|
217
|
+
},
|
|
218
|
+
async () => run(() => fetchLists())
|
|
219
|
+
);
|
|
220
|
+
server.registerTool(
|
|
221
|
+
"list_smart_lists",
|
|
222
|
+
{
|
|
223
|
+
title: "List smart lists",
|
|
224
|
+
description: 'Show all smart lists (saved filters) usable with list_todos view="smart".',
|
|
225
|
+
inputSchema: {}
|
|
226
|
+
},
|
|
227
|
+
async () => run(() => api.get("/smart-lists"))
|
|
228
|
+
);
|
|
229
|
+
server.registerTool(
|
|
230
|
+
"create_list",
|
|
231
|
+
{
|
|
232
|
+
title: "Create list",
|
|
233
|
+
description: "Create a new list.",
|
|
234
|
+
inputSchema: {
|
|
235
|
+
name: z.string(),
|
|
236
|
+
color: z.string().optional().describe("hex color e.g. #22c55e"),
|
|
237
|
+
parent: z.string().optional().describe("parent list name or id")
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
async ({ name, color, parent }) => run(async () => {
|
|
241
|
+
const payload = { name };
|
|
242
|
+
if (color !== void 0) payload.color = color;
|
|
243
|
+
if (parent !== void 0) payload.parent_id = (await resolveListArg(parent)).id;
|
|
244
|
+
return api.post("/lists", payload);
|
|
245
|
+
})
|
|
246
|
+
);
|
|
247
|
+
server.registerTool(
|
|
248
|
+
"rename_list",
|
|
249
|
+
{
|
|
250
|
+
title: "Rename list",
|
|
251
|
+
description: "Rename a list.",
|
|
252
|
+
inputSchema: { list: z.string().describe("list name or id"), new_name: z.string() }
|
|
253
|
+
},
|
|
254
|
+
async ({ list, new_name }) => run(async () => {
|
|
255
|
+
const target = await resolveListArg(list);
|
|
256
|
+
return api.put(`/lists/${target.id}`, { name: new_name });
|
|
257
|
+
})
|
|
258
|
+
);
|
|
259
|
+
server.registerTool(
|
|
260
|
+
"delete_list",
|
|
261
|
+
{
|
|
262
|
+
title: "Delete list",
|
|
263
|
+
description: "Delete a list and all of its todos. This is irreversible.",
|
|
264
|
+
inputSchema: { list: z.string().describe("list name or id") }
|
|
265
|
+
},
|
|
266
|
+
async ({ list }) => run(async () => {
|
|
267
|
+
const target = await resolveListArg(list);
|
|
268
|
+
await api.del(`/lists/${target.id}`);
|
|
269
|
+
return `Deleted list "${target.name}" (${target.todo_count} todos).`;
|
|
270
|
+
})
|
|
271
|
+
);
|
|
272
|
+
return server;
|
|
273
|
+
}
|
|
274
|
+
async function main() {
|
|
275
|
+
if (!loadCredentials()) {
|
|
276
|
+
process.stderr.write("kaname-mcp: no stored session; run `kaname login` first.\n");
|
|
277
|
+
}
|
|
278
|
+
const server = buildServer();
|
|
279
|
+
await server.connect(new StdioServerTransport());
|
|
280
|
+
}
|
|
281
|
+
main().catch((err) => {
|
|
282
|
+
process.stderr.write(`kaname-mcp failed to start: ${err.message}
|
|
283
|
+
`);
|
|
284
|
+
process.exit(1);
|
|
285
|
+
});
|
|
286
|
+
export {
|
|
287
|
+
buildServer
|
|
288
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kaname-tasks/kaname",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line client for Kaname",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"kaname": "dist/index.js",
|
|
8
|
+
"kaname-mcp": "dist/mcp.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup",
|
|
21
|
+
"dev": "tsx src/index.ts",
|
|
22
|
+
"lint": "tsc --noEmit",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"generate-types": "node scripts/generate-types.js"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@clack/prompts": "^0.11.0",
|
|
28
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
29
|
+
"commander": "^14.0.0",
|
|
30
|
+
"picocolors": "^1.1.1",
|
|
31
|
+
"zod": "^4.4.3"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.20.0",
|
|
35
|
+
"openapi-typescript": "^7.10.1",
|
|
36
|
+
"tsup": "^8.3.5",
|
|
37
|
+
"tsx": "^4.19.2",
|
|
38
|
+
"typescript": "^5.7.2",
|
|
39
|
+
"vitest": "^3.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|