@tiongcreative/assistant-mcp 0.1.1 → 0.3.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/CHANGELOG.md +16 -0
- package/README.md +22 -21
- package/package.json +2 -2
- package/src/apps/lists-and-notes.js +307 -5
- package/src/index.js +7 -0
- package/src/install.js +156 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-10-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Lists & Notes can now be changed, with Lists & Notes 1.23.0 or later: `lists_and_notes_add_items`, `lists_and_notes_update_items` (tick, untick, reword or annotate items named by their text), `lists_and_notes_add_list`, `lists_and_notes_add_note` and `lists_and_notes_update_note` (append, replace or rename). Changes to an existing list or note send the `modified` stamp last read; if the user changed it in the meantime the app refuses, and the tool re-reads and retries once. Item ids are always sent back. An older Lists & Notes gets a message saying which version writes need. No delete tool. End-to-end test against the real 1.23.0 server.
|
|
8
|
+
|
|
9
|
+
## [0.2.0] - 2026-10-19
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `npx @tiongcreative/assistant-mcp install` sets the assistant up in Claude Desktop: adds one entry to its config file with the full path to npx (a desktop app cannot see the shell's PATH), keeps a backup, refuses to touch a file it cannot parse, and prints the next steps. `uninstall` removes the entry; `status` reports it. `--allow-fresh` passes npm's release-age override into the entry for Macs that refuse packages younger than a few days. Unit tests cover create, merge, idempotence, corrupt files and removal.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- README setup is now the one-command path.
|
|
18
|
+
|
|
3
19
|
## [0.1.1] - 2026-10-19
|
|
4
20
|
|
|
5
21
|
### Changed
|
package/README.md
CHANGED
|
@@ -10,34 +10,35 @@ nothing else happens.
|
|
|
10
10
|
|
|
11
11
|
## Apps
|
|
12
12
|
|
|
13
|
-
| App
|
|
14
|
-
|
|
|
15
|
-
| Due Again 1.1.0 or later
|
|
16
|
-
| Gift Keeper 1.3.0 or later
|
|
17
|
-
| Client Manager 1.6.0 or later
|
|
18
|
-
| Lists & Notes 1.22.0 or later | lists with their items, notes with previews, the full text of a note |
|
|
13
|
+
| App | Reads | Changes |
|
|
14
|
+
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
15
|
+
| Due Again 1.1.0 or later | items with status and next due date, an item's history | add an item, record that an item was done |
|
|
16
|
+
| Gift Keeper 1.3.0 or later | people with interests, sizes and preferences; upcoming occasions with budget, spend and whether a gift is chosen | add a gift idea for a person, mark a gift as given |
|
|
17
|
+
| Client Manager 1.6.0 or later | pending reminders, open tasks, invoices owing with totals and balance, one client with their jobs | add a client, a job, a reminder or a task, change a job, task or reminder, log an activity |
|
|
18
|
+
| Lists & Notes 1.22.0 or later (changes need 1.23.0) | lists with their items, notes with previews, the full text of a note | add a list or a note, add items, tick off or reword items, add to or rename a note |
|
|
19
19
|
|
|
20
20
|
More apps follow, and the assistant can also open an app for you, but only after you agree.
|
|
21
21
|
|
|
22
22
|
## Setup
|
|
23
23
|
|
|
24
|
-
You need Node.js
|
|
25
|
-
(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"command": "npx",
|
|
32
|
-
"args": ["-y", "@tiongcreative/assistant-mcp"]
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
24
|
+
You need Node.js and Claude Desktop. If you do not have Node.js, install it from
|
|
25
|
+
nodejs.org (the LTS download), which also gives you the `npx` command used below.
|
|
26
|
+
|
|
27
|
+
Open Terminal and run:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
npx @tiongcreative/assistant-mcp install
|
|
36
31
|
```
|
|
37
32
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
It adds the assistant to Claude Desktop's settings and keeps a backup of the previous file.
|
|
34
|
+
Then quit Claude Desktop completely, open it again, and allow the "tiongcreative" server the
|
|
35
|
+
first time it asks. In each app, turn on **Allow assistant access** in Settings. With the apps
|
|
36
|
+
open, ask Claude what's on this week.
|
|
37
|
+
|
|
38
|
+
The assistant only offers tools for the apps installed on your Mac, checked when Claude
|
|
39
|
+
Desktop starts it, so after installing another app restart Claude Desktop once.
|
|
40
|
+
|
|
41
|
+
To remove it: `npx @tiongcreative/assistant-mcp uninstall`. To check: `npx @tiongcreative/assistant-mcp status`.
|
|
41
42
|
|
|
42
43
|
For Claude Code: `claude mcp add tiongcreative -- npx -y @tiongcreative/assistant-mcp`.
|
|
43
44
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiongcreative/assistant-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP server that lets an AI assistant on your Mac use your Tiong Creative apps while they are open.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"prepare": "husky",
|
|
35
35
|
"validate": "npm run lint && npm run format:check",
|
|
36
36
|
"quality": "npm run validate && npm run sast && npm run security && npm run secrets && npm run licenses && npm run deps:check && npm run review",
|
|
37
|
-
"test:unit": "node --test tests/dates.test.js",
|
|
37
|
+
"test:unit": "node --test tests/dates.test.js tests/install.test.js",
|
|
38
38
|
"test:integration": "node --test tests/due-again.test.js tests/gift-keeper.test.js tests/client-manager.test.js tests/lists-and-notes.test.js"
|
|
39
39
|
},
|
|
40
40
|
"license": "MIT",
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
// Lists & Notes: checklists and markdown notes.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Lists & Notes: checklists and markdown notes. Writes need Lists & Notes 1.23.0 or later,
|
|
2
|
+
// which gives the assistant its own per-list and per-note routes (the whole-file save is
|
|
3
|
+
// window-only). Changing an existing list or note sends the `modified` stamp last read
|
|
4
|
+
// (`baseModified`); if the user changed it since, the app refuses and the tool re-reads and
|
|
5
|
+
// retries once. Item ids are always sent back, or the app would treat items as new. There
|
|
6
|
+
// is no delete tool. Talks to the app's local API with the per-launch token it writes while
|
|
7
|
+
// "Allow assistant access" is on (list-manager/assistant-access.js).
|
|
5
8
|
import { z } from 'zod';
|
|
6
|
-
import { appConnection, apiCall, notAvailable } from '../connection.js';
|
|
9
|
+
import { ApiError, appConnection, apiCall, notAvailable } from '../connection.js';
|
|
7
10
|
|
|
8
11
|
export const app = {
|
|
9
12
|
id: 'lists-and-notes',
|
|
@@ -47,6 +50,63 @@ function matches(text, q) {
|
|
|
47
50
|
return typeof text === 'string' && text.toLowerCase().includes(q);
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
const WRITES_FROM = '1.23.0';
|
|
54
|
+
|
|
55
|
+
function olderThan(version, min) {
|
|
56
|
+
const a = String(version ?? '0')
|
|
57
|
+
.split('.')
|
|
58
|
+
.map(Number);
|
|
59
|
+
const b = min.split('.').map(Number);
|
|
60
|
+
for (let i = 0; i < 3; i++) {
|
|
61
|
+
if ((a[i] || 0) !== b[i]) return (a[i] || 0) < b[i];
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The app's version must support writes; returns a result to send back, or null. */
|
|
67
|
+
function needsUpdate(conn) {
|
|
68
|
+
if (!olderThan(conn.version, WRITES_FROM)) return null;
|
|
69
|
+
return {
|
|
70
|
+
app: app.name,
|
|
71
|
+
changed: false,
|
|
72
|
+
message: `Changing lists and notes needs Lists & Notes ${WRITES_FROM} or later (this Mac has ${conn.version ?? 'an older version'}). Reading still works.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function findList(conn, name) {
|
|
77
|
+
const lists = await apiCall(conn, 'GET', '/api/data/lists');
|
|
78
|
+
const wanted = name.trim().toLowerCase();
|
|
79
|
+
return {
|
|
80
|
+
list: lists.find((l) => l.name.toLowerCase() === wanted),
|
|
81
|
+
names: lists.map((l) => l.name),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function findNote(conn, title) {
|
|
86
|
+
const notes = await apiCall(conn, 'GET', '/api/data/notes');
|
|
87
|
+
const wanted = title.trim().toLowerCase();
|
|
88
|
+
return {
|
|
89
|
+
note: notes.find((n) => n.title.toLowerCase() === wanted),
|
|
90
|
+
titles: notes.map((n) => n.title),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Runs a change against the latest copy; on a 409 (changed in the window) re-reads once. */
|
|
95
|
+
async function withRetry(load, change) {
|
|
96
|
+
let current = await load();
|
|
97
|
+
try {
|
|
98
|
+
return await change(current);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
if (!(err instanceof ApiError) || err.status !== 409) throw err;
|
|
101
|
+
current = await load();
|
|
102
|
+
return change(current);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function itemMatches(item, text) {
|
|
107
|
+
return item.text.trim().toLowerCase() === text.trim().toLowerCase();
|
|
108
|
+
}
|
|
109
|
+
|
|
50
110
|
export const tools = [
|
|
51
111
|
{
|
|
52
112
|
name: 'lists_and_notes_lists',
|
|
@@ -156,6 +216,248 @@ export const tools = [
|
|
|
156
216
|
};
|
|
157
217
|
},
|
|
158
218
|
},
|
|
219
|
+
{
|
|
220
|
+
name: 'lists_and_notes_add_items',
|
|
221
|
+
description:
|
|
222
|
+
'Add items to an existing list in Lists & Notes, by list name. Items are added unticked at the end. If the list does not exist, the reply lists the lists.',
|
|
223
|
+
input: z.object({
|
|
224
|
+
list: z.string().min(1).max(75),
|
|
225
|
+
items: z.array(z.string().trim().min(1).max(2000)).min(1).max(100),
|
|
226
|
+
}),
|
|
227
|
+
write: true,
|
|
228
|
+
async run(input) {
|
|
229
|
+
const conn = await appConnection(app);
|
|
230
|
+
if (!conn.ok) return notAvailable(app, conn);
|
|
231
|
+
const old = needsUpdate(conn);
|
|
232
|
+
if (old) return old;
|
|
233
|
+
const { list, names } = await findList(conn, input.list);
|
|
234
|
+
if (!list)
|
|
235
|
+
return {
|
|
236
|
+
app: app.name,
|
|
237
|
+
changed: false,
|
|
238
|
+
message: `No list called "${input.list}".`,
|
|
239
|
+
lists: names,
|
|
240
|
+
};
|
|
241
|
+
const updated = await apiCall(
|
|
242
|
+
conn,
|
|
243
|
+
'POST',
|
|
244
|
+
`/api/data/lists/${encodeURIComponent(list.id)}/items`,
|
|
245
|
+
{
|
|
246
|
+
items: input.items.map((text) => ({ text })),
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
return { app: app.name, changed: true, list: summariseList(updated), added: input.items };
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
name: 'lists_and_notes_update_items',
|
|
254
|
+
description:
|
|
255
|
+
"Tick off, untick, reword or add notes to items on a list in Lists & Notes. Name the list and each item by its current text. Items not mentioned are left as they are. If an item's text does not match, the reply lists the items.",
|
|
256
|
+
input: z.object({
|
|
257
|
+
list: z.string().min(1).max(75),
|
|
258
|
+
changes: z
|
|
259
|
+
.array(
|
|
260
|
+
z.object({
|
|
261
|
+
item: z.string().min(1).max(2000).describe('The item as it reads now'),
|
|
262
|
+
done: z.boolean().optional(),
|
|
263
|
+
text: z.string().trim().min(1).max(2000).optional().describe('New wording'),
|
|
264
|
+
notes: z.string().max(2000).optional(),
|
|
265
|
+
}),
|
|
266
|
+
)
|
|
267
|
+
.min(1)
|
|
268
|
+
.max(100),
|
|
269
|
+
}),
|
|
270
|
+
write: true,
|
|
271
|
+
async run(input) {
|
|
272
|
+
const conn = await appConnection(app);
|
|
273
|
+
if (!conn.ok) return notAvailable(app, conn);
|
|
274
|
+
const old = needsUpdate(conn);
|
|
275
|
+
if (old) return old;
|
|
276
|
+
const first = await findList(conn, input.list);
|
|
277
|
+
if (!first.list)
|
|
278
|
+
return {
|
|
279
|
+
app: app.name,
|
|
280
|
+
changed: false,
|
|
281
|
+
message: `No list called "${input.list}".`,
|
|
282
|
+
lists: first.names,
|
|
283
|
+
};
|
|
284
|
+
const id = first.list.id;
|
|
285
|
+
const load = async () =>
|
|
286
|
+
(await apiCall(conn, 'GET', '/api/data/lists')).find((l) => l.id === id);
|
|
287
|
+
return withRetry(load, async (list) => {
|
|
288
|
+
if (!list)
|
|
289
|
+
return { app: app.name, changed: false, message: 'That list has just been removed.' };
|
|
290
|
+
const items = (list.items ?? []).map((i) => ({ ...i }));
|
|
291
|
+
const missing = input.changes
|
|
292
|
+
.filter((c) => !items.some((i) => itemMatches(i, c.item)))
|
|
293
|
+
.map((c) => c.item);
|
|
294
|
+
if (missing.length) {
|
|
295
|
+
return {
|
|
296
|
+
app: app.name,
|
|
297
|
+
changed: false,
|
|
298
|
+
message: `No item reading ${missing.map((m) => `"${m}"`).join(', ')} on ${list.name}.`,
|
|
299
|
+
items: items.map((i) => i.text),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
for (const c of input.changes) {
|
|
303
|
+
const item = items.find((i) => itemMatches(i, c.item));
|
|
304
|
+
if (c.done !== undefined) item.completed = c.done;
|
|
305
|
+
if (c.text !== undefined) item.text = c.text;
|
|
306
|
+
if (c.notes !== undefined) item.notes = c.notes;
|
|
307
|
+
}
|
|
308
|
+
const updated = await apiCall(conn, 'PATCH', `/api/data/lists/${encodeURIComponent(id)}`, {
|
|
309
|
+
items,
|
|
310
|
+
baseModified: list.metadata?.modified,
|
|
311
|
+
});
|
|
312
|
+
return { app: app.name, changed: true, list: summariseList(updated) };
|
|
313
|
+
});
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
name: 'lists_and_notes_add_list',
|
|
318
|
+
description:
|
|
319
|
+
'Create a new list in Lists & Notes with optional first items. The category must already exist in the app; if it does not, the reply lists the categories.',
|
|
320
|
+
input: z.object({
|
|
321
|
+
name: z.string().trim().min(1).max(75),
|
|
322
|
+
items: z.array(z.string().trim().min(1).max(2000)).max(100).optional(),
|
|
323
|
+
category: z.string().max(100).optional().describe('Defaults to personal'),
|
|
324
|
+
tags: z.array(z.string().max(50)).max(10).optional(),
|
|
325
|
+
}),
|
|
326
|
+
write: true,
|
|
327
|
+
async run(input) {
|
|
328
|
+
const conn = await appConnection(app);
|
|
329
|
+
if (!conn.ok) return notAvailable(app, conn);
|
|
330
|
+
const old = needsUpdate(conn);
|
|
331
|
+
if (old) return old;
|
|
332
|
+
const [{ list: existing }, categories] = await Promise.all([
|
|
333
|
+
findList(conn, input.name),
|
|
334
|
+
apiCall(conn, 'GET', '/api/data/categories'),
|
|
335
|
+
]);
|
|
336
|
+
if (existing)
|
|
337
|
+
return {
|
|
338
|
+
app: app.name,
|
|
339
|
+
added: false,
|
|
340
|
+
message: `There is already a list called "${input.name}".`,
|
|
341
|
+
};
|
|
342
|
+
const category = input.category
|
|
343
|
+
? categories.find((c) => c.toLowerCase() === input.category.trim().toLowerCase())
|
|
344
|
+
: 'personal';
|
|
345
|
+
if (!category)
|
|
346
|
+
return {
|
|
347
|
+
app: app.name,
|
|
348
|
+
added: false,
|
|
349
|
+
message: `No category called "${input.category}".`,
|
|
350
|
+
categories,
|
|
351
|
+
};
|
|
352
|
+
const list = await apiCall(conn, 'POST', '/api/data/lists/add', {
|
|
353
|
+
name: input.name,
|
|
354
|
+
category,
|
|
355
|
+
...(input.tags ? { tags: input.tags } : {}),
|
|
356
|
+
items: (input.items ?? []).map((text) => ({ text, completed: false, notes: '' })),
|
|
357
|
+
});
|
|
358
|
+
return { app: app.name, added: true, list: summariseList(list) };
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
name: 'lists_and_notes_add_note',
|
|
363
|
+
description:
|
|
364
|
+
'Create a note in Lists & Notes with a title and markdown content. The category must already exist; if it does not, the reply lists the categories.',
|
|
365
|
+
input: z.object({
|
|
366
|
+
title: z.string().trim().min(1).max(75),
|
|
367
|
+
content: z.string().max(200000).default(''),
|
|
368
|
+
category: z.string().max(100).optional().describe('Defaults to personal'),
|
|
369
|
+
tags: z.array(z.string().max(50)).max(10).optional(),
|
|
370
|
+
}),
|
|
371
|
+
write: true,
|
|
372
|
+
async run(input) {
|
|
373
|
+
const conn = await appConnection(app);
|
|
374
|
+
if (!conn.ok) return notAvailable(app, conn);
|
|
375
|
+
const old = needsUpdate(conn);
|
|
376
|
+
if (old) return old;
|
|
377
|
+
const categories = await apiCall(conn, 'GET', '/api/data/categories');
|
|
378
|
+
const category = input.category
|
|
379
|
+
? categories.find((c) => c.toLowerCase() === input.category.trim().toLowerCase())
|
|
380
|
+
: 'personal';
|
|
381
|
+
if (!category)
|
|
382
|
+
return {
|
|
383
|
+
app: app.name,
|
|
384
|
+
added: false,
|
|
385
|
+
message: `No category called "${input.category}".`,
|
|
386
|
+
categories,
|
|
387
|
+
};
|
|
388
|
+
const note = await apiCall(conn, 'POST', '/api/data/notes', {
|
|
389
|
+
title: input.title,
|
|
390
|
+
category,
|
|
391
|
+
content: input.content,
|
|
392
|
+
...(input.tags ? { tags: input.tags } : {}),
|
|
393
|
+
});
|
|
394
|
+
return { app: app.name, added: true, note: summariseNote(note) };
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
name: 'lists_and_notes_update_note',
|
|
399
|
+
description:
|
|
400
|
+
"Change a note in Lists & Notes, by title: add text to the end (append), replace its text, or rename it. If the user edits the note at the same moment, the change is applied to their latest version; the app keeps the previous text in the note's history.",
|
|
401
|
+
input: z.object({
|
|
402
|
+
title: z.string().min(1).max(75).describe('The note as it is titled now'),
|
|
403
|
+
append: z.string().max(200000).optional().describe('Text added on a new line at the end'),
|
|
404
|
+
content: z.string().max(200000).optional().describe('Replaces the whole text'),
|
|
405
|
+
newTitle: z.string().trim().min(1).max(75).optional(),
|
|
406
|
+
}),
|
|
407
|
+
write: true,
|
|
408
|
+
async run(input) {
|
|
409
|
+
const conn = await appConnection(app);
|
|
410
|
+
if (!conn.ok) return notAvailable(app, conn);
|
|
411
|
+
const old = needsUpdate(conn);
|
|
412
|
+
if (old) return old;
|
|
413
|
+
if (
|
|
414
|
+
input.append === undefined &&
|
|
415
|
+
input.content === undefined &&
|
|
416
|
+
input.newTitle === undefined
|
|
417
|
+
) {
|
|
418
|
+
return {
|
|
419
|
+
app: app.name,
|
|
420
|
+
changed: false,
|
|
421
|
+
message: 'Nothing to change: give text to append, new content or a new title.',
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
if (input.append !== undefined && input.content !== undefined) {
|
|
425
|
+
return {
|
|
426
|
+
app: app.name,
|
|
427
|
+
changed: false,
|
|
428
|
+
message: 'Give either append or content, not both.',
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const first = await findNote(conn, input.title);
|
|
432
|
+
if (!first.note)
|
|
433
|
+
return {
|
|
434
|
+
app: app.name,
|
|
435
|
+
changed: false,
|
|
436
|
+
message: `No note called "${input.title}".`,
|
|
437
|
+
titles: first.titles,
|
|
438
|
+
};
|
|
439
|
+
const id = first.note.id;
|
|
440
|
+
const load = () => apiCall(conn, 'GET', `/api/data/notes/${encodeURIComponent(id)}`);
|
|
441
|
+
return withRetry(load, async (note) => {
|
|
442
|
+
const body = { baseModified: note.metadata?.modified };
|
|
443
|
+
if (input.content !== undefined) body.content = input.content;
|
|
444
|
+
if (input.append !== undefined) {
|
|
445
|
+
const current = typeof note.content === 'string' ? note.content : '';
|
|
446
|
+
body.content = current
|
|
447
|
+
? `${current.replace(/\s+$/, '')}\n\n${input.append}`
|
|
448
|
+
: input.append;
|
|
449
|
+
}
|
|
450
|
+
if (input.newTitle !== undefined) body.title = input.newTitle;
|
|
451
|
+
const updated = await apiCall(
|
|
452
|
+
conn,
|
|
453
|
+
'PUT',
|
|
454
|
+
`/api/data/notes/${encodeURIComponent(id)}`,
|
|
455
|
+
body,
|
|
456
|
+
);
|
|
457
|
+
return { app: app.name, changed: true, note: summariseNote(updated) };
|
|
458
|
+
});
|
|
459
|
+
},
|
|
460
|
+
},
|
|
159
461
|
];
|
|
160
462
|
|
|
161
463
|
/** Lists & Notes has no dated items, so it adds nothing to "what's on". */
|
package/src/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
11
11
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { ApiError, isInstalled } from './connection.js';
|
|
14
|
+
import { runCli } from './install.js';
|
|
14
15
|
import * as dueAgain from './apps/due-again.js';
|
|
15
16
|
import * as giftKeeper from './apps/gift-keeper.js';
|
|
16
17
|
import * as clientManager from './apps/client-manager.js';
|
|
@@ -18,6 +19,12 @@ import * as listsAndNotes from './apps/lists-and-notes.js';
|
|
|
18
19
|
|
|
19
20
|
const { version } = createRequire(import.meta.url)('../package.json');
|
|
20
21
|
|
|
22
|
+
// `npx @tiongcreative/assistant-mcp install|uninstall|status` is the member's setup path.
|
|
23
|
+
// With no command, this process is the MCP server Claude Desktop launches.
|
|
24
|
+
if (process.argv.length > 2) {
|
|
25
|
+
process.exit(runCli(process.argv.slice(2)));
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
// One entry per app. Add an app by adding a module with the same shape.
|
|
22
29
|
const ALL_APPS = [dueAgain, giftKeeper, clientManager, listsAndNotes];
|
|
23
30
|
// Only apps present on this Mac get tools, so the assistant never points the user at an
|
package/src/install.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// `npx @tiongcreative/assistant-mcp install`: sets the assistant up in Claude Desktop so a
|
|
2
|
+
// member never edits JSON by hand. It adds one entry to Claude Desktop's config file (and
|
|
3
|
+
// nothing else), keeps a backup, and records the full path to npx, because a desktop app
|
|
4
|
+
// does not see the shell's PATH and would otherwise fail to find it.
|
|
5
|
+
//
|
|
6
|
+
// `uninstall` removes the entry. `status` says whether it is set up.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const SERVER_KEY = 'tiongcreative';
|
|
12
|
+
const PACKAGE = '@tiongcreative/assistant-mcp';
|
|
13
|
+
|
|
14
|
+
/** Claude Desktop's config file on this Mac (TC_CLAUDE_CONFIG overrides it, for tests). */
|
|
15
|
+
export function configPath() {
|
|
16
|
+
if (process.env.TC_CLAUDE_CONFIG) return process.env.TC_CLAUDE_CONFIG;
|
|
17
|
+
if (process.platform !== 'darwin') {
|
|
18
|
+
throw new Error('The Tiong Creative apps run on macOS, so this installer does too.');
|
|
19
|
+
}
|
|
20
|
+
return path.join(
|
|
21
|
+
os.homedir(),
|
|
22
|
+
'Library',
|
|
23
|
+
'Application Support',
|
|
24
|
+
'Claude',
|
|
25
|
+
'claude_desktop_config.json',
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The npx beside the node that is running this command: the one Claude Desktop will use. */
|
|
30
|
+
export function npxPath() {
|
|
31
|
+
const candidate = path.join(path.dirname(process.execPath), 'npx');
|
|
32
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
33
|
+
throw new Error(`Could not find npx next to node at ${process.execPath}.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readConfig(file) {
|
|
37
|
+
if (!fs.existsSync(file)) return { config: {}, existed: false };
|
|
38
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
39
|
+
if (!raw.trim()) return { config: {}, existed: true };
|
|
40
|
+
try {
|
|
41
|
+
const config = JSON.parse(raw);
|
|
42
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
43
|
+
throw new Error('not an object');
|
|
44
|
+
}
|
|
45
|
+
return { config, existed: true };
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Claude Desktop's config file could not be read as JSON (${err.message}), so nothing was changed. File: ${file}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeConfig(file, config, existed) {
|
|
54
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
55
|
+
let backup = null;
|
|
56
|
+
if (existed) {
|
|
57
|
+
backup = `${file}.backup-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
58
|
+
fs.copyFileSync(file, backup);
|
|
59
|
+
}
|
|
60
|
+
const tmp = `${file}.tmp`;
|
|
61
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n');
|
|
62
|
+
fs.renameSync(tmp, file);
|
|
63
|
+
return backup;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function install({ env } = {}) {
|
|
67
|
+
const file = configPath();
|
|
68
|
+
const { config, existed } = readConfig(file);
|
|
69
|
+
const entry = { command: npxPath(), args: ['-y', PACKAGE] };
|
|
70
|
+
if (env && Object.keys(env).length) entry.env = env;
|
|
71
|
+
const servers =
|
|
72
|
+
config.mcpServers && typeof config.mcpServers === 'object' ? config.mcpServers : {};
|
|
73
|
+
const already = JSON.stringify(servers[SERVER_KEY]) === JSON.stringify(entry);
|
|
74
|
+
config.mcpServers = { ...servers, [SERVER_KEY]: entry };
|
|
75
|
+
const backup = already ? null : writeConfig(file, config, existed);
|
|
76
|
+
return { file, backup, already, entry };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function uninstall() {
|
|
80
|
+
const file = configPath();
|
|
81
|
+
const { config, existed } = readConfig(file);
|
|
82
|
+
if (!existed || !config.mcpServers || !(SERVER_KEY in config.mcpServers)) {
|
|
83
|
+
return { file, removed: false };
|
|
84
|
+
}
|
|
85
|
+
const rest = { ...config.mcpServers };
|
|
86
|
+
delete rest[SERVER_KEY];
|
|
87
|
+
config.mcpServers = rest;
|
|
88
|
+
const backup = writeConfig(file, config, true);
|
|
89
|
+
return { file, removed: true, backup };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function status() {
|
|
93
|
+
const file = configPath();
|
|
94
|
+
try {
|
|
95
|
+
const { config, existed } = readConfig(file);
|
|
96
|
+
const entry = existed ? config.mcpServers?.[SERVER_KEY] : undefined;
|
|
97
|
+
return { file, installed: !!entry, entry: entry ?? null };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return { file, installed: false, error: err.message };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const NEXT_STEPS = [
|
|
104
|
+
'1. Quit Claude Desktop completely and open it again.',
|
|
105
|
+
'2. The first time you chat, allow the "tiongcreative" server when Claude Desktop asks.',
|
|
106
|
+
'3. In each Tiong Creative app, open Settings and turn on "Allow assistant access".',
|
|
107
|
+
'4. With the apps open, ask Claude what is on this week.',
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/** Runs the command and returns the process exit code. Prints with the given writer. */
|
|
111
|
+
export function runCli(args, print = console.log) {
|
|
112
|
+
const [command] = args;
|
|
113
|
+
try {
|
|
114
|
+
if (command === 'install') {
|
|
115
|
+
const env = {};
|
|
116
|
+
// Developer convenience: `install --allow-fresh` passes npm's release-age override
|
|
117
|
+
// through to the server entry, for Macs that refuse packages younger than a few days.
|
|
118
|
+
if (args.includes('--allow-fresh')) env.npm_config_min_release_age = '0';
|
|
119
|
+
const r = install({ env });
|
|
120
|
+
print(
|
|
121
|
+
r.already
|
|
122
|
+
? `The Tiong Creative assistant is already set up in Claude Desktop (${r.file}).`
|
|
123
|
+
: `Added the Tiong Creative assistant to Claude Desktop.\n Config: ${r.file}${r.backup ? `\n Backup of the previous file: ${r.backup}` : ''}`,
|
|
124
|
+
);
|
|
125
|
+
print('\nNext:\n' + NEXT_STEPS.join('\n'));
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
if (command === 'uninstall') {
|
|
129
|
+
const r = uninstall();
|
|
130
|
+
print(
|
|
131
|
+
r.removed
|
|
132
|
+
? `Removed the Tiong Creative assistant from Claude Desktop (${r.file}). Restart Claude Desktop to finish.`
|
|
133
|
+
: `The Tiong Creative assistant was not set up in Claude Desktop (${r.file}), so nothing changed.`,
|
|
134
|
+
);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
if (command === 'status') {
|
|
138
|
+
const r = status();
|
|
139
|
+
if (r.error) print(r.error);
|
|
140
|
+
else
|
|
141
|
+
print(
|
|
142
|
+
r.installed
|
|
143
|
+
? `Set up in Claude Desktop: ${r.file}`
|
|
144
|
+
: `Not set up in Claude Desktop (${r.file}).`,
|
|
145
|
+
);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
print(
|
|
149
|
+
`Usage:\n npx ${PACKAGE} install add the assistant to Claude Desktop\n npx ${PACKAGE} uninstall remove it\n npx ${PACKAGE} status say whether it is set up\n\nWith no command, this runs the MCP server itself (Claude Desktop does that for you).`,
|
|
150
|
+
);
|
|
151
|
+
return command === 'help' || command === '--help' ? 0 : 2;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
print(`Could not finish: ${err.message}`);
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
}
|