@difflab/difflab-cli 0.1.0-rc.6 → 0.2.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.
Files changed (5) hide show
  1. package/LICENSE +202 -21
  2. package/NOTICE +2 -0
  3. package/README.md +3 -14
  4. package/dist/index.js +358 -159
  5. package/package.json +4 -3
package/LICENSE CHANGED
@@ -1,21 +1,202 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 cloudvoyant
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,2 @@
1
+ Difflab CLI
2
+ Copyright 2026 cloudvoyant
package/README.md CHANGED
@@ -5,10 +5,10 @@ A Bun command-line application with a local Difflab MCP server.
5
5
  ## Run
6
6
 
7
7
  ```bash
8
- bunx @difflab/difflab-cli Premise
8
+ bunx @difflab/difflab-cli
9
9
  ```
10
10
 
11
- ## MCP todo tools
11
+ ## MCP setup
12
12
 
13
13
  After installing the CLI so `difflab-cli` is on your PATH, register it with the MCP clients you use:
14
14
 
@@ -19,18 +19,7 @@ difflab-cli mcp setup --client cursor --client codex
19
19
 
20
20
  Clients are selected explicitly. Supported names: `pi`, `cursor`, `codex`, `claude-code`, and `claude-desktop`. Pi uses the shared `~/.config/mcp/mcp.json` file (through the Pi MCP adapter), Cursor uses `~/.cursor/mcp.json`, Codex uses its CLI, Claude Code uses its CLI with user scope, and Claude Desktop uses its platform-specific user config. These clients do **not** all read one common file. Setup adds Difflab only when absent, leaves other servers in place, and refuses to replace a conflicting `difflab` entry. Codex and Claude Code must have their CLIs installed if selected. Restart or reload the selected client after setup.
21
21
 
22
- `mcp setup` does **not** create a todo file. Use the `todo_init` MCP tool to create a list. MCP clients launch `npx -y @difflab/difflab-cli mcp serve` over stdio, so no daemon or port is needed. Bun must be on the client's PATH.
23
-
24
- Each todo call requires **`cwd` (an absolute path to an existing directory)** and **`path` (a relative path to a JSON todo file inside that directory)**. They are supplied per call, not in the MCP server configuration. For example, with `cwd` set to `/home/alex`:
25
-
26
- ```text
27
- todo_init({"cwd":"/home/alex","path":"todos.json"})
28
- todo_add({"cwd":"/home/alex","path":"todos.json","text":"Review PR"})
29
- ```
30
-
31
- `todo_list`, `todo_complete`, and `todo_remove` also require both fields; the latter two require a task `id` returned by `todo_list` or `todo_add`. Call `todo_init` before other operations on a new file. Parent traversal outside `cwd`, absolute `path` values, and symlinked paths are rejected. Keep personal todo files outside the repository or add their paths to `.gitignore`. Concurrent writes to the same file are not yet coordinated.
32
-
33
- Agents in this repo can load `skills/difflab-todo/SKILL.md` for usage instructions.
22
+ MCP clients launch `npx -y @difflab/difflab-cli mcp serve` over stdio, so no daemon or port is needed. Bun must be on the client's PATH.
34
23
 
35
24
  ## Develop
36
25
 
package/dist/index.js CHANGED
@@ -27336,9 +27336,6 @@ var init_stdio2 = __esm(() => {
27336
27336
  });
27337
27337
 
27338
27338
  // src/tools.ts
27339
- function result(value) {
27340
- return { content: [{ type: "text", text: JSON.stringify(value) }] };
27341
- }
27342
27339
  function defineTool(name, config, handler) {
27343
27340
  return {
27344
27341
  name,
@@ -27347,26 +27344,36 @@ function defineTool(name, config, handler) {
27347
27344
  inputSchema: config.inputSchema,
27348
27345
  ...config.annotations ? { annotations: config.annotations } : {}
27349
27346
  },
27350
- callback: async (input) => {
27347
+ callback: async (untrustedInput) => {
27351
27348
  try {
27352
- return result(await handler(config.inputSchema.parse(input)));
27349
+ return serializeToolResult(await handler(config.inputSchema.parse(untrustedInput)));
27353
27350
  } catch (error) {
27354
27351
  return {
27355
- ...result({ error: error instanceof Error ? error.message : String(error) }),
27352
+ ...serializeToolResult({ error: error instanceof Error ? error.message : String(error) }),
27356
27353
  isError: true
27357
27354
  };
27358
27355
  }
27359
27356
  }
27360
27357
  };
27361
27358
  }
27359
+ function serializeToolResult(value) {
27360
+ return { content: [{ type: "text", text: JSON.stringify(value) }] };
27361
+ }
27362
27362
 
27363
- // src/todos/path.ts
27364
- import { lstat, realpath, stat } from "fs/promises";
27365
- import { isAbsolute, join, relative, resolve, sep } from "path";
27366
- function isFileError(error, code) {
27363
+ // src/extensions/osx.ts
27364
+ import { homedir } from "os";
27365
+ function resolveHomeDirectory(home) {
27366
+ return home ?? homedir();
27367
+ }
27368
+ function hasErrorCode(error, code) {
27367
27369
  return error instanceof Error && "code" in error && error.code === code;
27368
27370
  }
27369
- async function resolveTodoPath(cwd, path) {
27371
+ var init_osx = () => {};
27372
+
27373
+ // src/extensions/pathx.ts
27374
+ import { lstat, realpath, stat } from "fs/promises";
27375
+ import { isAbsolute, join, relative, resolve, sep } from "path";
27376
+ async function resolvePath(cwd, path) {
27370
27377
  if (!isAbsolute(cwd))
27371
27378
  throw new Error("cwd must be an absolute path to an existing directory");
27372
27379
  if (!path.trim() || isAbsolute(path))
@@ -27384,126 +27391,271 @@ async function resolveTodoPath(cwd, path) {
27384
27391
  current = join(current, segment);
27385
27392
  try {
27386
27393
  if ((await lstat(current)).isSymbolicLink()) {
27387
- throw new Error(`Symlink paths are not allowed for todo files: ${current}`);
27394
+ throw new Error(`Symlink paths are not allowed for files: ${current}`);
27388
27395
  }
27389
27396
  } catch (error) {
27390
- if (isFileError(error, "ENOENT"))
27397
+ if (hasErrorCode(error, "ENOENT"))
27391
27398
  break;
27392
27399
  throw error;
27393
27400
  }
27394
27401
  }
27395
27402
  return file;
27396
27403
  }
27397
- var init_path = () => {};
27404
+ var init_pathx = __esm(() => {
27405
+ init_osx();
27406
+ });
27398
27407
 
27399
- // src/todos/store.ts
27408
+ // src/stores/json-file-store.ts
27400
27409
  import { randomUUID } from "crypto";
27401
27410
  import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
27402
27411
  import { dirname, resolve as resolve2 } from "path";
27403
- function isFileError2(error, code) {
27404
- return error instanceof Error && "code" in error && error.code === code;
27405
- }
27406
27412
 
27407
- class TodoStore {
27413
+ class JsonFileStore {
27414
+ schema;
27408
27415
  file;
27409
- constructor(file) {
27416
+ itemsKey;
27417
+ metadata;
27418
+ envelopeMetadata = {};
27419
+ constructor(file, schema, options) {
27420
+ this.schema = schema;
27410
27421
  this.file = resolve2(file);
27422
+ this.itemsKey = options?.itemsKey;
27423
+ this.metadata = options?.metadata ?? {};
27411
27424
  }
27412
- async initialize() {
27425
+ async read(id) {
27426
+ const item = (await this.list()).find((candidate) => candidate.id === id);
27427
+ if (!item)
27428
+ throw new JsonFileStoreMissingItemError(`Item does not exist: ${id}`);
27429
+ return item;
27430
+ }
27431
+ async list() {
27432
+ const envelope = await this.load();
27433
+ const raw = this.itemsKey ? envelope[this.itemsKey] : envelope;
27434
+ if (!Array.isArray(raw)) {
27435
+ throw new JsonFileStoreValidationError(`Invalid JSON store at ${this.file}`);
27436
+ }
27437
+ const items = raw.map((item) => this.parse(item));
27438
+ this.validateUniqueIds(items);
27439
+ return items;
27440
+ }
27441
+ async write(item) {
27442
+ const value = this.parse(item);
27443
+ const items = await this.list();
27444
+ const index = items.findIndex((candidate) => candidate.id === value.id);
27445
+ if (index === -1)
27446
+ items.push(value);
27447
+ else
27448
+ items[index] = value;
27449
+ await this.persist(items);
27450
+ }
27451
+ async replace(items) {
27452
+ const validated = items.map((item) => this.parse(item));
27453
+ this.validateUniqueIds(validated);
27454
+ await this.persist(validated);
27455
+ }
27456
+ async update(id, mutation) {
27457
+ const items = await this.list();
27458
+ const index = items.findIndex((item) => item.id === id);
27459
+ if (index === -1)
27460
+ throw new JsonFileStoreMissingItemError(`Item does not exist: ${id}`);
27461
+ const current = items[index];
27462
+ const updated = this.parse(mutation(current) ?? current);
27463
+ items[index] = updated;
27464
+ this.validateUniqueIds(items);
27465
+ await this.persist(items);
27466
+ return updated;
27467
+ }
27468
+ async initialize(items = []) {
27413
27469
  await mkdir(dirname(this.file), { recursive: true });
27414
- const list = { version: 1, tasks: [] };
27470
+ const validated = items.map((item) => this.parse(item));
27471
+ this.validateUniqueIds(validated);
27415
27472
  try {
27416
- await writeFile(this.file, `${JSON.stringify(list, null, 2)}
27417
- `, { flag: "wx", mode: 384 });
27418
- return { created: true, list };
27473
+ await writeFile(this.file, this.serialize(validated), { flag: "wx", mode: 384 });
27474
+ return true;
27475
+ } catch (error) {
27476
+ if (error.code === "EEXIST")
27477
+ return false;
27478
+ throw new JsonFileStoreWriteError(`Could not write JSON file: ${this.file}`, { cause: error });
27479
+ }
27480
+ }
27481
+ async load() {
27482
+ let content;
27483
+ try {
27484
+ content = await readFile(this.file, "utf8");
27419
27485
  } catch (error) {
27420
- if (!isFileError2(error, "EEXIST"))
27486
+ const ErrorType = error.code === "ENOENT" ? JsonFileStoreNotFoundError : JsonFileStoreReadError;
27487
+ throw new ErrorType(`Could not read JSON file: ${this.file}`, { cause: error });
27488
+ }
27489
+ try {
27490
+ const parsed = JSON.parse(content);
27491
+ if (!this.itemsKey)
27492
+ return parsed;
27493
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
27494
+ throw new Error("Envelope must be an object");
27495
+ const envelope = parsed;
27496
+ for (const [key, value] of Object.entries(this.metadata)) {
27497
+ if (envelope[key] !== value)
27498
+ throw new Error(`Invalid metadata: ${key}`);
27499
+ }
27500
+ const metadata = { ...envelope };
27501
+ delete metadata[this.itemsKey];
27502
+ this.envelopeMetadata = metadata;
27503
+ return envelope;
27504
+ } catch (error) {
27505
+ if (error instanceof JsonFileStoreValidationError)
27421
27506
  throw error;
27422
- return { created: false, list: await this.read() };
27507
+ throw new JsonFileStoreValidationError(`Invalid JSON store at ${this.file}`, { cause: error });
27423
27508
  }
27424
27509
  }
27425
- async list() {
27426
- return this.read();
27510
+ parse(value) {
27511
+ try {
27512
+ return this.schema.parse(value);
27513
+ } catch (error) {
27514
+ throw new JsonFileStoreValidationError(`Invalid item for ${this.file}`, { cause: error });
27515
+ }
27427
27516
  }
27428
- async add(text) {
27429
- const value = string2().trim().min(1).parse(text);
27430
- const list = await this.read();
27431
- const task = { id: randomUUID(), text: value, done: false };
27432
- list.tasks.push(task);
27433
- await this.write(list);
27434
- return task;
27517
+ validateUniqueIds(items) {
27518
+ if (new Set(items.map((item) => item.id)).size !== items.length) {
27519
+ throw new JsonFileStoreValidationError(`Invalid item IDs for ${this.file}`);
27520
+ }
27435
27521
  }
27436
- async complete(id) {
27437
- const list = await this.read();
27438
- const task = list.tasks.find((item) => item.id === id);
27439
- if (!task)
27440
- throw new Error(`Unknown task ID: ${id}`);
27441
- task.done = true;
27442
- await this.write(list);
27443
- return task;
27522
+ serialize(items) {
27523
+ const payload = this.itemsKey ? { ...this.envelopeMetadata, ...this.metadata, [this.itemsKey]: items } : items;
27524
+ return `${JSON.stringify(payload, null, 2)}
27525
+ `;
27444
27526
  }
27445
- async remove(id) {
27446
- const list = await this.read();
27447
- const index = list.tasks.findIndex((item) => item.id === id);
27448
- if (index === -1)
27449
- throw new Error(`Unknown task ID: ${id}`);
27450
- const [task] = list.tasks.splice(index, 1);
27451
- await this.write(list);
27452
- return task;
27527
+ async persist(items) {
27528
+ const temp = `${this.file}.${randomUUID()}.tmp`;
27529
+ try {
27530
+ await writeFile(temp, this.serialize(items), { mode: 384 });
27531
+ await rename(temp, this.file);
27532
+ } catch (error) {
27533
+ throw new JsonFileStoreWriteError(`Could not write JSON file: ${this.file}`, { cause: error });
27534
+ } finally {
27535
+ await rm(temp, { force: true });
27536
+ }
27453
27537
  }
27454
- async read() {
27455
- let content;
27538
+ }
27539
+ var JsonFileStoreError, JsonFileStoreReadError, JsonFileStoreNotFoundError, JsonFileStoreWriteError, JsonFileStoreValidationError, JsonFileStoreMissingItemError;
27540
+ var init_json_file_store = __esm(() => {
27541
+ JsonFileStoreError = class JsonFileStoreError extends Error {
27542
+ constructor(message, options) {
27543
+ super(message, options);
27544
+ this.name = new.target.name;
27545
+ }
27546
+ };
27547
+ JsonFileStoreReadError = class JsonFileStoreReadError extends JsonFileStoreError {
27548
+ };
27549
+ JsonFileStoreNotFoundError = class JsonFileStoreNotFoundError extends JsonFileStoreReadError {
27550
+ };
27551
+ JsonFileStoreWriteError = class JsonFileStoreWriteError extends JsonFileStoreError {
27552
+ };
27553
+ JsonFileStoreValidationError = class JsonFileStoreValidationError extends JsonFileStoreError {
27554
+ };
27555
+ JsonFileStoreMissingItemError = class JsonFileStoreMissingItemError extends JsonFileStoreReadError {
27556
+ };
27557
+ });
27558
+
27559
+ // src/todos/store.ts
27560
+ import { randomUUID as randomUUID2 } from "crypto";
27561
+ import { mkdir as mkdir2 } from "fs/promises";
27562
+ import { dirname as dirname2, resolve as resolve3 } from "path";
27563
+
27564
+ class TodoStore {
27565
+ file;
27566
+ tasks;
27567
+ constructor(file) {
27568
+ this.file = resolve3(file);
27569
+ this.tasks = new JsonFileStore(this.file, taskSchema, {
27570
+ itemsKey: "tasks",
27571
+ metadata: { version: 1 }
27572
+ });
27573
+ }
27574
+ async initialize() {
27575
+ await mkdir2(dirname2(this.file), { recursive: true });
27576
+ const created = await this.tasks.initialize();
27577
+ return { created, list: created ? { version: 1, tasks: [] } : await this.list() };
27578
+ }
27579
+ async list() {
27456
27580
  try {
27457
- content = await readFile(this.file, "utf8");
27581
+ return { version: 1, tasks: await this.tasks.list() };
27458
27582
  } catch (error) {
27459
- if (isFileError2(error, "ENOENT")) {
27583
+ if (error instanceof JsonFileStoreNotFoundError) {
27460
27584
  throw new Error(`Todo list does not exist: ${this.file}. Call todo_init first.`, {
27461
27585
  cause: error
27462
27586
  });
27463
27587
  }
27588
+ if (error instanceof JsonFileStoreValidationError) {
27589
+ throw new Error(`Invalid todo list at ${this.file}`, { cause: error });
27590
+ }
27464
27591
  throw error;
27465
27592
  }
27593
+ }
27594
+ async add(text) {
27595
+ const task = { id: randomUUID2(), text: string2().trim().min(1).parse(text), done: false };
27466
27596
  try {
27467
- const list = listSchema.parse(JSON.parse(content));
27468
- if (new Set(list.tasks.map((task) => task.id)).size !== list.tasks.length) {
27469
- throw new Error("duplicate task IDs");
27470
- }
27471
- return list;
27597
+ await this.tasks.write(task);
27598
+ return task;
27472
27599
  } catch (error) {
27473
- throw new Error(`Invalid todo list at ${this.file}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
27600
+ this.rethrowDomainError(error);
27474
27601
  }
27475
27602
  }
27476
- async write(list) {
27477
- const temp = `${this.file}.${randomUUID()}.tmp`;
27603
+ async complete(id) {
27478
27604
  try {
27479
- await writeFile(temp, `${JSON.stringify(list, null, 2)}
27480
- `, { flag: "wx", mode: 384 });
27481
- await rename(temp, this.file);
27482
- } finally {
27483
- await rm(temp, { force: true });
27605
+ return await this.tasks.update(id, (task) => {
27606
+ task.done = true;
27607
+ });
27608
+ } catch (error) {
27609
+ if (error instanceof JsonFileStoreMissingItemError)
27610
+ throw new Error(`Unknown task ID: ${id}`, { cause: error });
27611
+ this.rethrowDomainError(error);
27484
27612
  }
27485
27613
  }
27614
+ async remove(id) {
27615
+ let list;
27616
+ try {
27617
+ list = await this.tasks.list();
27618
+ } catch (error) {
27619
+ this.rethrowDomainError(error);
27620
+ }
27621
+ const task = list.find((item) => item.id === id);
27622
+ if (!task)
27623
+ throw new Error(`Unknown task ID: ${id}`);
27624
+ try {
27625
+ await this.tasks.replace(list.filter((item) => item.id !== id));
27626
+ return task;
27627
+ } catch (error) {
27628
+ this.rethrowDomainError(error);
27629
+ }
27630
+ }
27631
+ rethrowDomainError(error) {
27632
+ if (error instanceof JsonFileStoreNotFoundError) {
27633
+ throw new Error(`Todo list does not exist: ${this.file}. Call todo_init first.`, {
27634
+ cause: error
27635
+ });
27636
+ }
27637
+ if (error instanceof JsonFileStoreValidationError) {
27638
+ throw new Error(`Invalid todo list at ${this.file}`, { cause: error });
27639
+ }
27640
+ throw error;
27641
+ }
27486
27642
  }
27487
- var taskSchema, listSchema;
27643
+ var taskSchema;
27488
27644
  var init_store = __esm(() => {
27489
27645
  init_zod();
27490
- taskSchema = strictObject({
27491
- id: uuid2(),
27492
- text: string2().min(1),
27493
- done: boolean2()
27494
- });
27495
- listSchema = strictObject({ version: literal(1), tasks: array(taskSchema) });
27646
+ init_json_file_store();
27647
+ taskSchema = strictObject({ id: uuid2(), text: string2().min(1), done: boolean2() });
27496
27648
  });
27497
27649
 
27498
27650
  // src/todos/tools.ts
27499
27651
  import { isAbsolute as isAbsolute2 } from "path";
27500
27652
  async function store(cwd, path) {
27501
- return new TodoStore(await resolveTodoPath(cwd, path));
27653
+ return new TodoStore(await resolvePath(cwd, path));
27502
27654
  }
27503
27655
  var cwd, path2, id, fileInput, todoTools;
27504
27656
  var init_tools = __esm(() => {
27505
27657
  init_zod();
27506
- init_path();
27658
+ init_pathx();
27507
27659
  init_store();
27508
27660
  cwd = string2().refine(isAbsolute2, "cwd must be an absolute path").describe("Absolute path to an existing base directory, such as the repository root. Never use the server process cwd implicitly.");
27509
27661
  path2 = string2().min(1).refine((value) => value.trim().length > 0 && !isAbsolute2(value), "path must be relative to cwd").describe("Relative path to the JSON todo file inside cwd, such as todos.json or .local/todos.json. No escaping cwd or symlinks.");
@@ -27541,98 +27693,95 @@ var init_tools = __esm(() => {
27541
27693
  ];
27542
27694
  });
27543
27695
 
27544
- // src/mcp.ts
27545
- function createDifflabServer(version = "0.1.0") {
27696
+ // src/mcp/server.ts
27697
+ async function serveMcp(version = "0.1.0") {
27546
27698
  const server = new McpServer({ name: "difflab", version });
27547
27699
  for (const tool of todoTools) {
27548
27700
  server.registerTool(tool.name, tool.config, tool.callback);
27549
27701
  }
27550
- return server;
27551
- }
27552
- async function serveMcp(version) {
27553
- await createDifflabServer(version).connect(new StdioServerTransport);
27702
+ await server.connect(new StdioServerTransport);
27554
27703
  }
27555
- var init_mcp2 = __esm(() => {
27704
+ var init_server3 = __esm(() => {
27556
27705
  init_mcp();
27557
27706
  init_stdio2();
27558
27707
  init_tools();
27559
27708
  });
27560
27709
 
27561
- // src/agents/adapter.ts
27710
+ // src/errors.ts
27711
+ var DifflabError, ConfigurationError, CommandExecutionError;
27712
+ var init_errors4 = __esm(() => {
27713
+ DifflabError = class DifflabError extends Error {
27714
+ constructor(message, options) {
27715
+ super(message, options);
27716
+ this.name = "DifflabError";
27717
+ }
27718
+ };
27719
+ ConfigurationError = class ConfigurationError extends DifflabError {
27720
+ constructor(message, options) {
27721
+ super(message, options);
27722
+ this.name = "ConfigurationError";
27723
+ }
27724
+ };
27725
+ CommandExecutionError = class CommandExecutionError extends DifflabError {
27726
+ constructor(command, message, options) {
27727
+ super(`Could not configure ${command}: ${message}`, options);
27728
+ this.name = "CommandExecutionError";
27729
+ }
27730
+ };
27731
+ });
27732
+
27733
+ // src/extensions/processx.ts
27562
27734
  import { execFileSync } from "child_process";
27563
- import { homedir } from "os";
27564
- function setupHome(context) {
27565
- return context.home ?? homedir();
27566
- }
27567
27735
  function runCommand(command, args, cwd) {
27568
27736
  try {
27569
27737
  execFileSync(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
27570
27738
  } catch (error) {
27571
- throw new Error(`Could not configure ${command}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
27739
+ throw new CommandExecutionError(command, error instanceof Error ? error.message : String(error), { cause: error });
27572
27740
  }
27573
27741
  }
27574
- function isFileError3(error, code) {
27575
- return error instanceof Error && "code" in error && error.code === code;
27742
+ var init_processx = __esm(() => {
27743
+ init_errors4();
27744
+ });
27745
+
27746
+ // src/agents/adapter.ts
27747
+ function setupHome(context) {
27748
+ return resolveHomeDirectory(context.home);
27576
27749
  }
27577
27750
  var mcpServer;
27578
27751
  var init_adapter = __esm(() => {
27752
+ init_osx();
27753
+ init_processx();
27579
27754
  mcpServer = {
27580
27755
  command: "npx",
27581
27756
  args: ["-y", "@difflab/difflab-cli", "mcp", "serve"]
27582
27757
  };
27583
27758
  });
27584
27759
 
27585
- // src/agents/json-config.ts
27586
- import { randomUUID as randomUUID2 } from "crypto";
27587
- import { lstat as lstat2, mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
27588
- import { dirname as dirname2 } from "path";
27760
+ // src/extensions/fsx.ts
27761
+ import { randomUUID as randomUUID3 } from "crypto";
27762
+ import { lstat as lstat2, mkdir as mkdir3, readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
27763
+ import { dirname as dirname3 } from "path";
27589
27764
  function isObject2(value) {
27590
27765
  return value !== null && typeof value === "object" && !Array.isArray(value);
27591
27766
  }
27592
- async function readJsonConfig(file) {
27767
+ async function readJsonFile(file) {
27593
27768
  try {
27594
- if ((await lstat2(file)).isSymbolicLink()) {
27595
- throw new Error(`MCP config at ${file} is a symlink; refusing to replace it`);
27596
- }
27597
- const content = await readFile2(file, "utf8");
27598
- try {
27599
- const parsed = JSON.parse(content);
27600
- if (!isObject2(parsed))
27601
- throw new Error("root must be a JSON object");
27602
- return parsed;
27603
- } catch (error) {
27604
- throw new Error(`Invalid MCP config at ${file}; not modified: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
27605
- }
27769
+ if ((await lstat2(file)).isSymbolicLink())
27770
+ throw new Error(`JSON file at ${file} is a symlink`);
27771
+ const parsed = JSON.parse(await readFile2(file, "utf8"));
27772
+ if (!isObject2(parsed))
27773
+ throw new Error("root must be a JSON object");
27774
+ return parsed;
27606
27775
  } catch (error) {
27607
- if (isFileError3(error, "ENOENT"))
27776
+ if (hasErrorCode(error, "ENOENT"))
27608
27777
  return {};
27609
27778
  throw error;
27610
27779
  }
27611
27780
  }
27612
- function serverStatus(config, file) {
27613
- if (config.mcpServers !== undefined && !isObject2(config.mcpServers)) {
27614
- throw new Error(`Invalid MCP config at ${file}; mcpServers must be an object`);
27615
- }
27616
- if (!isObject2(config.mcpServers) || !Object.hasOwn(config.mcpServers, "difflab")) {
27617
- return "missing";
27618
- }
27619
- const entry = config.mcpServers.difflab;
27620
- return isObject2(entry) && entry.command === mcpServer.command && Array.isArray(entry.args) && entry.args.length === mcpServer.args.length && entry.args.every((arg, index) => arg === mcpServer.args[index]) ? "matching" : "conflicting";
27621
- }
27622
- async function addJsonServer(file) {
27623
- const config = await readJsonConfig(file);
27624
- const status = serverStatus(config, file);
27625
- if (status === "matching")
27626
- return { file, status: "existing" };
27627
- if (status === "conflicting") {
27628
- throw new Error(`MCP server "difflab" already exists in ${file} with a different configuration; not modified`);
27629
- }
27630
- const updated = {
27631
- ...config,
27632
- mcpServers: { ...isObject2(config.mcpServers) ? config.mcpServers : {}, difflab: mcpServer }
27633
- };
27634
- await mkdir2(dirname2(file), { recursive: true });
27635
- const temp = `${file}.${randomUUID2()}.tmp`;
27781
+ async function patchJsonToFile(file, patch) {
27782
+ const updated = patch(await readJsonFile(file));
27783
+ await mkdir3(dirname3(file), { recursive: true });
27784
+ const temp = `${file}.${randomUUID3()}.tmp`;
27636
27785
  try {
27637
27786
  await writeFile2(temp, `${JSON.stringify(updated, null, 2)}
27638
27787
  `, { flag: "wx", mode: 384 });
@@ -27640,7 +27789,27 @@ async function addJsonServer(file) {
27640
27789
  } finally {
27641
27790
  await rm2(temp, { force: true });
27642
27791
  }
27643
- return { file, status: "added" };
27792
+ }
27793
+ var init_fsx = __esm(() => {
27794
+ init_osx();
27795
+ });
27796
+
27797
+ // src/mcp/json-config.ts
27798
+ async function readJsonConfig(file) {
27799
+ try {
27800
+ return await readJsonFile(file);
27801
+ } catch (error) {
27802
+ throw new ConfigurationError(`Invalid MCP config at ${file}; not modified: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
27803
+ }
27804
+ }
27805
+ function serverStatus(config, file) {
27806
+ if (config.mcpServers !== undefined && !isObject2(config.mcpServers)) {
27807
+ throw new ConfigurationError(`Invalid MCP config at ${file}; mcpServers must be an object`);
27808
+ }
27809
+ if (!isObject2(config.mcpServers) || !Object.hasOwn(config.mcpServers, "difflab"))
27810
+ return "missing";
27811
+ const entry = config.mcpServers.difflab;
27812
+ return isObject2(entry) && entry.command === mcpServer.command && Array.isArray(entry.args) && entry.args.length === mcpServer.args.length && entry.args.every((arg, index) => arg === mcpServer.args[index]) ? "matching" : "conflicting";
27644
27813
  }
27645
27814
  function jsonAgentAdapter(id, configPath) {
27646
27815
  return {
@@ -27650,8 +27819,23 @@ function jsonAgentAdapter(id, configPath) {
27650
27819
  }
27651
27820
  };
27652
27821
  }
27822
+ async function addJsonServer(file) {
27823
+ const config = await readJsonConfig(file);
27824
+ const status = serverStatus(config, file);
27825
+ if (status === "matching")
27826
+ return { file, status: "existing" };
27827
+ if (status === "conflicting")
27828
+ throw new ConfigurationError(`MCP server "difflab" already exists in ${file} with a different configuration; not modified`);
27829
+ await patchJsonToFile(file, (current) => ({
27830
+ ...current,
27831
+ mcpServers: { ...isObject2(current.mcpServers) ? current.mcpServers : {}, difflab: mcpServer }
27832
+ }));
27833
+ return { file, status: "added" };
27834
+ }
27653
27835
  var init_json_config = __esm(() => {
27654
27836
  init_adapter();
27837
+ init_errors4();
27838
+ init_fsx();
27655
27839
  });
27656
27840
 
27657
27841
  // src/agents/claude-code.ts
@@ -27659,6 +27843,7 @@ import { join as join2 } from "path";
27659
27843
  var claudeCodeAdapter;
27660
27844
  var init_claude_code = __esm(() => {
27661
27845
  init_adapter();
27846
+ init_errors4();
27662
27847
  init_json_config();
27663
27848
  claudeCodeAdapter = {
27664
27849
  id: "claude-code",
@@ -27669,7 +27854,7 @@ var init_claude_code = __esm(() => {
27669
27854
  if (status === "matching")
27670
27855
  return { file, status: "existing" };
27671
27856
  if (status === "conflicting") {
27672
- throw new Error(`MCP server "difflab" already exists in ${file} with a different configuration; not modified`);
27857
+ throw new ConfigurationError(`MCP server "difflab" already exists in ${file} with a different configuration; not modified`);
27673
27858
  }
27674
27859
  const run = context.run ?? runCommand;
27675
27860
  run("claude", ["mcp", "add", "--scope", "user", "difflab", "--", mcpServer.command, ...mcpServer.args], home);
@@ -27698,6 +27883,8 @@ function codexServerStatus(content) {
27698
27883
  var codexAdapter;
27699
27884
  var init_codex = __esm(() => {
27700
27885
  init_adapter();
27886
+ init_errors4();
27887
+ init_osx();
27701
27888
  codexAdapter = {
27702
27889
  id: "codex",
27703
27890
  async setupMcpConfig(context = {}) {
@@ -27707,14 +27894,14 @@ var init_codex = __esm(() => {
27707
27894
  try {
27708
27895
  content = await readFile3(file, "utf8");
27709
27896
  } catch (error) {
27710
- if (!isFileError3(error, "ENOENT"))
27897
+ if (!hasErrorCode(error, "ENOENT"))
27711
27898
  throw error;
27712
27899
  }
27713
27900
  const status = codexServerStatus(content);
27714
27901
  if (status === "matching")
27715
27902
  return { file, status: "existing" };
27716
27903
  if (status === "conflicting") {
27717
- throw new Error(`Codex already has a different difflab entry in ${file}; not modified`);
27904
+ throw new ConfigurationError(`Codex already has a different difflab entry in ${file}; not modified`);
27718
27905
  }
27719
27906
  const run = context.run ?? runCommand;
27720
27907
  run("codex", ["mcp", "add", "difflab", "--", mcpServer.command, ...mcpServer.args], home);
@@ -27754,6 +27941,33 @@ var init_agents = __esm(() => {
27754
27941
  agentNames = Object.keys(agentAdapters);
27755
27942
  });
27756
27943
 
27944
+ // src/mcp/setup.ts
27945
+ async function setupMcpClients(clients, write) {
27946
+ if (clients.length === 0) {
27947
+ throw new Error(`Select at least one client with --client (${agentNames.join(", ")})`);
27948
+ }
27949
+ const adapters = [...new Set(clients)].map((name) => {
27950
+ const adapter = agentAdapter(name);
27951
+ if (!adapter)
27952
+ throw new Error(`Unknown MCP client: ${name}. Choose ${agentNames.join(", ")}`);
27953
+ return adapter;
27954
+ });
27955
+ for (const adapter of adapters) {
27956
+ const { file, status } = await adapter.setupMcpConfig();
27957
+ write(`${status === "added" ? "Added" : "Already configured"} ${adapter.id}: ${file}`);
27958
+ }
27959
+ write("Restart or reload the selected client to use the Difflab MCP tools.");
27960
+ }
27961
+ var init_setup = __esm(() => {
27962
+ init_agents();
27963
+ });
27964
+
27965
+ // src/mcp/index.ts
27966
+ var init_mcp2 = __esm(() => {
27967
+ init_server3();
27968
+ init_setup();
27969
+ });
27970
+
27757
27971
  // ../../node_modules/commander/lib/error.js
27758
27972
  class CommanderError extends Error {
27759
27973
  constructor(exitCode, code, message) {
@@ -29799,36 +30013,21 @@ function useColor() {
29799
30013
  var program = new Command;
29800
30014
 
29801
30015
  // src/cli.ts
29802
- function greeting(name) {
29803
- return `Hello, ${name}!`;
29804
- }
29805
30016
  function createProgram(write = console.log, version = "0.1.0") {
29806
- const program = new Command().name("difflab-cli").description("Difflab CLI and local MCP server").version(version).argument("[name]", "name to greet", "world").action((name) => write(greeting(name)));
30017
+ const program = new Command().name("difflab-cli").description("Difflab CLI and local MCP server").version(version);
30018
+ program.action(() => program.outputHelp());
29807
30019
  const mcp = program.command("mcp").description("Difflab MCP server commands");
29808
30020
  mcp.command("serve").description("Serve Difflab tools over MCP stdio").action(async () => {
29809
30021
  await Promise.resolve().then(() => init_mcp2());
29810
30022
  await serveMcp(version);
29811
30023
  });
29812
30024
  mcp.command("setup").description("Register the installed Difflab MCP server in selected user-level clients").option("--client <name>", "pi, cursor, codex, claude-code, or claude-desktop (repeatable)", (name, selected) => [...selected, name], []).action(async ({ client }) => {
29813
- await Promise.resolve().then(() => init_agents());
29814
- if (client.length === 0) {
29815
- throw new Error(`Select at least one client with --client (${agentNames.join(", ")})`);
29816
- }
29817
- const adapters = [...new Set(client)].map((name) => {
29818
- const adapter = agentAdapter(name);
29819
- if (!adapter)
29820
- throw new Error(`Unknown MCP client: ${name}. Choose ${agentNames.join(", ")}`);
29821
- return adapter;
29822
- });
29823
- for (const adapter of adapters) {
29824
- const { file, status } = await adapter.setupMcpConfig();
29825
- write(`${status === "added" ? "Added" : "Already configured"} ${adapter.id}: ${file}`);
29826
- }
29827
- write("Restart or reload the selected client to use the Difflab MCP tools.");
30025
+ await Promise.resolve().then(() => init_mcp2());
30026
+ await setupMcpClients(client, write);
29828
30027
  });
29829
30028
  return program;
29830
30029
  }
29831
30030
 
29832
30031
  // src/index.ts
29833
- var version2 = "0.1.0-rc.6";
30032
+ var version2 = "0.2.0";
29834
30033
  await createProgram(console.log, version2).parseAsync(process.argv);
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@difflab/difflab-cli",
3
- "version": "0.1.0-rc.6",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "A minimal Bun CLI starter built with Commander",
6
- "license": "MIT",
6
+ "license": "Apache-2.0",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/difflab-io/difflab.git",
@@ -20,7 +20,8 @@
20
20
  "files": [
21
21
  "dist",
22
22
  "README.md",
23
- "LICENSE"
23
+ "LICENSE",
24
+ "NOTICE"
24
25
  ],
25
26
  "publishConfig": {
26
27
  "access": "public",