cadet-agent 0.15.0 → 0.15.2

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 (3) hide show
  1. package/README.md +28 -9
  2. package/package.json +4 -1
  3. package/src/install.mjs +472 -337
package/README.md CHANGED
@@ -7,7 +7,7 @@ Cadet-Agent is **not a one-shot code generator**. It won't spit out a finished g
7
7
  ## Repository Layout
8
8
  - `.cadet/agent/core/` contains the shared Cadet-Agent framework documents.
9
9
  - `.cadet/agent/docs/` contains setup guides for each supported IDE.
10
- - `.github/agents/` contains the Copilot custom agent definition (agent mode).
10
+ - `.github/agents/` contains the Copilot custom agent definitions (Cadet Agent + Cadet Agent Reviewer).
11
11
  - `.cursor/` contains Cursor-specific authored files.
12
12
  - `.continue/` contains Continue-specific authored files.
13
13
  - `.claude/` contains Claude Code-specific authored files.
@@ -27,6 +27,20 @@ This downloads the latest framework release and extracts it into your current di
27
27
  npx cadet-agent@latest init --target ./my-unity-project
28
28
  ```
29
29
 
30
+ ### Keeping the Framework Updated
31
+
32
+ ```bash
33
+ npx cadet-agent@latest sync
34
+ ```
35
+
36
+ When a new release is available, `sync` downloads the updated framework and replaces managed files (`.cadet/agent/core/`, IDE integration shims, agent definitions). Your local policies (`.cadet/agent/policies/`) and project plans (`.cadet/agent/project-plans/`) are automatically preserved. After syncing, start a fresh chat for the changes to take effect.
37
+
38
+ To sync a specific directory:
39
+
40
+ ```bash
41
+ npx cadet-agent@latest sync --target ./my-unity-project
42
+ ```
43
+
30
44
  ## Manual Install (fallback)
31
45
 
32
46
  If you prefer to install from a packaged release artifact, download `cadet-agent.zip` from [GitHub Releases](https://github.com/naishtech/cadet-agent/releases) and extract it into your Unity project root:
@@ -36,25 +50,29 @@ Expand-Archive .\cadet-agent.zip -DestinationPath . -Force
36
50
  ```
37
51
 
38
52
  ## Getting Started
39
- - For repository setup and package contents, see [.cadet/agent/README.md](.cadet/agent/README.md).
40
- - For framework navigation, see [.cadet/agent/core/README.md](.cadet/agent/core/README.md).
41
- - For GitHub Copilot setup, see [.cadet/agent/docs/github-copilot.md](.cadet/agent/docs/github-copilot.md).
42
- - For Cursor setup, see [.cadet/agent/docs/cursor.md](.cadet/agent/docs/cursor.md).
43
- - For Continue setup, see [.cadet/agent/docs/continue.md](.cadet/agent/docs/continue.md).
44
- - For Claude Code setup, see [.cadet/agent/docs/claude-code.md](.cadet/agent/docs/claude-code.md).
53
+ - For framework navigation after install, see `.cadet/agent/core/README.md`.
54
+ - IDE setup guides and full documentation are at the [canonical repository](https://github.com/naishtech/cadet-agent) (GitHub Pages).
45
55
 
46
56
  ## Examples
47
57
 
48
58
  ### GitHub Copilot
49
59
  Run `npx cadet-agent@latest init` in your Unity project root, then open the repo in VS Code.
50
60
 
51
- **Agent mode:** Select the **Cadet** agent from the agent picker in Copilot Chat. The agent definition at `.github/agents/cadet.agent.md` provides focused instructions and tool configuration.
61
+ **Agent mode:** Select the **Cadet Agent** agent from the agent picker in Copilot Chat. The agent definition at `.github/agents/cadet.agent.md` provides focused instructions and tool configuration.
52
62
 
53
63
  ```text
54
64
  [Describe your game dev task...]
55
65
  ```
56
66
 
57
- Cadet will use the shared framework in `.cadet/agent/core` to route the conversation through learner calibration, bootstrap checks, and planning.
67
+ Cadet Agent will use the shared framework in `.cadet/agent/core` to route the conversation through learner calibration, bootstrap checks, and planning.
68
+
69
+ **Review mode:** After the Cadet Agent completes a task, select the **Cadet Agent Reviewer** from the agent picker. Provide the task, story, or PR to review:
70
+
71
+ ```text
72
+ Review the PR at https://github.com/... or Review story-1 in epic-1-player-movement
73
+ ```
74
+
75
+ The reviewer will read `.cadet/agent/core/cadet-agent.md` as the rulebook, audit `.cadet/state.json` for gate compliance, and check the code and artifacts against every non-negotiable rule. It produces a structured report with a gate audit, process deviations, and recommendations — it does not edit code.
58
76
 
59
77
  ### Cursor feature request
60
78
  After opening the repository in Cursor, the always-apply rule in `.cursor/rules/cadet-agent.md` should load automatically. A typical request looks like this:
@@ -84,6 +102,7 @@ If a specific game repository needs local conventions, add a policy file under `
84
102
  Running `./package-agent.ps1` produces `cadet-agent.zip` with this layout:
85
103
  - `.cadet/agent/core/`
86
104
  - `.github/agents/cadet.agent.md`
105
+ - `.github/agents/cadet-agent-reviewer.agent.md`
87
106
  - `.cursor/rules/cadet-agent.md`
88
107
  - `.continue/rules/cadet-agent.md`
89
108
  - `.claude/skills/cadet-agent.md`
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "cadet-agent",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cadet-agent": "bin/cli.mjs"
8
8
  },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.mjs"
11
+ },
9
12
  "files": [
10
13
  "bin/",
11
14
  "src/"
package/src/install.mjs CHANGED
@@ -1,337 +1,472 @@
1
- import { createWriteStream, mkdirSync, existsSync, readFileSync } from 'node:fs';
2
- import { join, dirname } from 'node:path';
3
- import { pipeline } from 'node:stream/promises';
4
- import { createGunzip } from 'node:zlib';
5
- import { inflateRawSync } from 'node:zlib';
6
- import { tmpdir } from 'node:os';
7
- import { Readable } from 'node:stream';
8
-
9
- // ── Constants ────────────────────────────────────────────────────────────────
10
-
11
- const DEFAULT_API = 'https://api.github.com/repos/naishtech/cadet-agent/releases/latest';
12
- const USER_AGENT = 'cadet-agent-cli';
13
-
14
- function resolveApiUrl(override) {
15
- return override || process.env.CADET_AGENT_RELEASE_URL || DEFAULT_API;
16
- }
17
-
18
- // ── ZIP parser (zero-dependency, handles store + deflate) ───────────────────
19
-
20
- const SIG_EOCD = 0x06054b50;
21
- const SIG_CD = 0x02014b50;
22
- const SIG_LFH = 0x04034b50;
23
-
24
- function read32(buf, off) { return buf.readUInt32LE(off); }
25
- function read16(buf, off) { return buf.readUInt16LE(off); }
26
-
27
- function findEocd(buf) {
28
- // Search backwards from end for EOCD signature (comment is max 65535 bytes)
29
- const maxStart = Math.max(0, buf.length - 65535 - 22);
30
- for (let i = buf.length - 22; i >= maxStart; i--) {
31
- if (read32(buf, i) === SIG_EOCD) return i;
32
- }
33
- throw new Error('Not a valid ZIP file: EOCD signature not found');
34
- }
35
-
36
- function* centralDirectoryEntries(buf, cdOffset, cdSize) {
37
- let off = cdOffset;
38
- const end = cdOffset + cdSize;
39
- while (off < end) {
40
- if (read32(buf, off) !== SIG_CD) break;
41
- const method = read16(buf, off + 10);
42
- const compressedSize = read32(buf, off + 20);
43
- const uncompressedSize = read32(buf, off + 24);
44
- const filenameLen = read16(buf, off + 28);
45
- const extraLen = read16(buf, off + 30);
46
- const commentLen = read16(buf, off + 32);
47
- const localHeaderOff = read32(buf, off + 42);
48
- const filename = buf.toString('utf-8', off + 46, off + 46 + filenameLen);
49
-
50
- // Skip directory entries (trailing / in filename, or uncompressedSize == 0 with no method)
51
- if (!filename.endsWith('/')) {
52
- yield { filename, method, compressedSize, uncompressedSize, localHeaderOff };
53
- }
54
-
55
- off += 46 + filenameLen + extraLen + commentLen;
56
- }
57
- }
58
-
59
- function extractFile(buf, entry, targetDir) {
60
- const { filename, method, compressedSize, localHeaderOff } = entry;
61
-
62
- // Read local file header to get filename + extra lengths (they may differ from CD)
63
- const lfhFilenameLen = read16(buf, localHeaderOff + 26);
64
- const lfhExtraLen = read16(buf, localHeaderOff + 28);
65
-
66
- const dataStart = localHeaderOff + 30 + lfhFilenameLen + lfhExtraLen;
67
- const compressed = buf.subarray(dataStart, dataStart + compressedSize);
68
-
69
- let data;
70
- if (method === 0) {
71
- // Stored — no compression
72
- data = compressed;
73
- } else if (method === 8) {
74
- // Deflate
75
- data = inflateRawSync(compressed);
76
- } else {
77
- throw new Error(
78
- `Unsupported compression method ${method} for ${filename}.\n` +
79
- `This zip uses a compression format this CLI doesn't support.\n` +
80
- `Try downloading cadet-agent.zip manually from:\n` +
81
- ` https://github.com/naishtech/cadet-agent/releases/latest`
82
- );
83
- }
84
-
85
- const outPath = join(targetDir, filename);
86
- mkdirSync(dirname(outPath), { recursive: true });
87
- const ws = createWriteStream(outPath);
88
- Readable.from(data).pipe(ws);
89
-
90
- return outPath;
91
- }
92
-
93
- function extractZip(buf, targetDir) {
94
- const eocdOff = findEocd(buf);
95
- const cdSize = read32(buf, eocdOff + 12);
96
- const cdOff = read32(buf, eocdOff + 16);
97
-
98
- // ZIP64 detection 0xFFFFFFFF in 32-bit fields means real values are in ZIP64 extra records
99
- if (cdOff === 0xFFFFFFFF || cdSize === 0xFFFFFFFF) {
100
- throw new Error(
101
- 'This zip uses ZIP64 format, which is not supported.\n' +
102
- 'Try downloading cadet-agent.zip manually from:\n' +
103
- ' https://github.com/naishtech/cadet-agent/releases/latest'
104
- );
105
- }
106
-
107
- const paths = [];
108
- for (const entry of centralDirectoryEntries(buf, cdOff, cdSize)) {
109
- const outPath = extractFile(buf, entry, targetDir);
110
- paths.push(outPath);
111
- }
112
- return paths;
113
- }
114
-
115
- // ── GitHub release download ─────────────────────────────────────────────────
116
-
117
- async function fetchLatestRelease(apiUrl) {
118
- const url = resolveApiUrl(apiUrl);
119
- console.log('🔍 Fetching latest Cadet-Agent release...');
120
-
121
- const res = await fetch(url, {
122
- headers: {
123
- 'User-Agent': USER_AGENT,
124
- 'Accept': 'application/vnd.github+json',
125
- },
126
- });
127
-
128
- if (!res.ok) {
129
- if (res.status === 403 || res.status === 429) {
130
- throw new Error(
131
- `GitHub API rate-limited (${res.status}). ` +
132
- 'Set GITHUB_TOKEN env var for higher limits, or try again later.'
133
- );
134
- }
135
- throw new Error(`GitHub API returned ${res.status}: ${res.statusText}`);
136
- }
137
-
138
- return res.json();
139
- }
140
-
141
- function findZipAsset(release) {
142
- const asset = release.assets?.find(a => a.name === 'cadet-agent.zip');
143
- if (!asset) {
144
- throw new Error(
145
- `Release ${release.tag_name} does not contain cadet-agent.zip.\n` +
146
- `Available assets: ${(release.assets || []).map(a => a.name).join(', ') || 'none'}`
147
- );
148
- }
149
- return asset;
150
- }
151
-
152
- async function downloadZip(url) {
153
- console.log('⬇️ Downloading cadet-agent.zip...');
154
-
155
- const res = await fetch(url, {
156
- headers: {
157
- 'User-Agent': USER_AGENT,
158
- 'Accept': 'application/octet-stream',
159
- },
160
- });
161
-
162
- if (!res.ok) {
163
- throw new Error(`Download failed: ${res.status} ${res.statusText}`);
164
- }
165
-
166
- const contentLength = res.headers.get('content-length');
167
- const total = contentLength ? parseInt(contentLength, 10) : 0;
168
-
169
- // Stream to buffer with progress
170
- const chunks = [];
171
- let downloaded = 0;
172
- const reader = res.body.getReader();
173
-
174
- while (true) {
175
- const { done, value } = await reader.read();
176
- if (done) break;
177
- chunks.push(value);
178
- downloaded += value.length;
179
- if (total > 0) {
180
- const pct = Math.round((downloaded / total) * 100);
181
- process.stdout.write(`\r ${pct}% (${(downloaded / 1024).toFixed(0)} KB / ${(total / 1024).toFixed(0)} KB)`);
182
- }
183
- }
184
- if (total > 0) process.stdout.write('\n');
185
-
186
- return Buffer.concat(chunks);
187
- }
188
-
189
- // ── Public install entry ────────────────────────────────────────────────────
190
-
191
- export async function install(targetDir, opts = {}) {
192
- console.log(`📦 Cadet-Agent installing to ${targetDir}\n`);
193
-
194
- // 1. Fetch release metadata
195
- const release = await fetchLatestRelease(opts.sourceUrl);
196
- console.log(` Latest: ${release.tag_name} (published ${release.published_at})\n`);
197
-
198
- // 2. Find zip asset
199
- const asset = findZipAsset(release);
200
-
201
- // 3. Download
202
- const zipBuf = await downloadZip(asset.browser_download_url);
203
- console.log(` Downloaded ${(zipBuf.length / 1024).toFixed(0)} KB\n`);
204
-
205
- // 4. Extract
206
- console.log('📂 Extracting...');
207
- const extracted = extractZip(zipBuf, targetDir);
208
-
209
- // 5. Report
210
- console.log(`\n✅ Cadet-Agent ${release.tag_name} installed! Extracted ${extracted.length} files.\n`);
211
-
212
- // Print per-IDE next steps
213
- console.log('── Next steps ──');
214
- console.log(' GitHub Copilot:');
215
- console.log(' Start a chat: /cadet');
216
- console.log(' Cursor:');
217
- console.log(' Already active — .cursor\\rules\\cadet-agent.md loads automatically');
218
- console.log(' Continue:');
219
- console.log(' Already active — .continue\\rules\\cadet-agent.md loads automatically');
220
- console.log(' Claude Code:');
221
- console.log(' Already active — .claude\\skills\\cadet-agent.md loads as a project skill');
222
- console.log('');
223
- }
224
-
225
- // ── Manifest-aware extraction ───────────────────────────────────────────────
226
-
227
- function matchesPreservedPath(filename, preservedPaths) {
228
- // Normalize: strip leading dot (zip paths like ".cadet/agent/policies/...")
229
- const normalized = filename.replace(/^\.?\/?/, '');
230
- for (const preserved of preservedPaths) {
231
- const p = preserved.replace(/^\.?\/?/, '');
232
- if (normalized === p || normalized.startsWith(p + '/') || normalized.startsWith(p + '\\')) {
233
- return true;
234
- }
235
- }
236
- return false;
237
- }
238
-
239
- function extractZipWithManifest(buf, targetDir, { preserved, managed }) {
240
- const eocdOff = findEocd(buf);
241
- const cdSize = read32(buf, eocdOff + 12);
242
- const cdOff = read32(buf, eocdOff + 16);
243
-
244
- if (cdOff === 0xFFFFFFFF || cdSize === 0xFFFFFFFF) {
245
- throw new Error(
246
- 'This zip uses ZIP64 format, which is not supported.\n' +
247
- 'Try downloading cadet-agent.zip manually from:\n' +
248
- ' https://github.com/naishtech/cadet-agent/releases/latest'
249
- );
250
- }
251
-
252
- const updated = [];
253
- const preserved_list = [];
254
- const added = [];
255
-
256
- for (const entry of centralDirectoryEntries(buf, cdOff, cdSize)) {
257
- if (matchesPreservedPath(entry.filename, preserved)) {
258
- preserved_list.push(entry.filename);
259
- continue;
260
- }
261
- const outPath = extractFile(buf, entry, targetDir);
262
- // Check if this file already existed (updated vs added)
263
- // We can't easily tell without checking before extraction, so approximate:
264
- // If it's under a managed path, call it updated; otherwise added
265
- const isManaged = managed.some(m => {
266
- const mn = m.replace(/^\.?\/?/, '');
267
- const fn = entry.filename.replace(/^\.?\/?/, '');
268
- return fn === mn || fn.startsWith(mn + '/') || fn.startsWith(mn + '\\');
269
- });
270
- if (isManaged) {
271
- updated.push(outPath);
272
- } else {
273
- added.push(outPath);
274
- }
275
- }
276
-
277
- return { updated, preserved: preserved_list, added };
278
- }
279
-
280
- // ── Public sync entry ───────────────────────────────────────────────────────
281
-
282
- export async function sync(targetDir, opts = {}) {
283
- console.log(`🔄 Cadet-Agent syncing ${targetDir}\n`);
284
-
285
- // 1. Read existing manifest
286
- const manifestPath = join(targetDir, '.cadet', 'agent', 'core', 'FrameworkManifest.json');
287
- let existingManifest = null;
288
- let oldVersion = 'none';
289
- try {
290
- existingManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
291
- oldVersion = existingManifest.frameworkVersion || 'unknown';
292
- console.log(` Existing install: v${oldVersion}`);
293
- } catch {
294
- console.log(' No existing install found — performing full install.\n');
295
- return install(targetDir, opts);
296
- }
297
-
298
- // 2. Fetch release metadata
299
- const release = await fetchLatestRelease(opts.sourceUrl);
300
- const newVersion = release.tag_name;
301
- console.log(` Latest: ${newVersion} (published ${release.published_at})\n`);
302
-
303
- if (oldVersion === newVersion) {
304
- console.log(`✅ Already up to date (v${oldVersion}). Nothing to sync.\n`);
305
- return;
306
- }
307
-
308
- // 3. Download
309
- const asset = findZipAsset(release);
310
- const zipBuf = await downloadZip(asset.browser_download_url);
311
- console.log(` Downloaded ${(zipBuf.length / 1024).toFixed(0)} KB\n`);
312
-
313
- // 4. Extract with manifest awareness
314
- console.log('📂 Extracting (preserving local policies and plans)...');
315
- const result = extractZipWithManifest(zipBuf, targetDir, {
316
- preserved: existingManifest.preservedPaths || [],
317
- managed: existingManifest.managedPaths || [],
318
- });
319
-
320
- // 5. Report
321
- console.log('');
322
- console.log(`✅ Cadet-Agent synced: v${oldVersion} → ${newVersion}`);
323
- console.log(` Updated: ${result.updated.length} files`);
324
- if (result.preserved.length > 0) {
325
- console.log(` Preserved: ${result.preserved.length} files (local policies/plans)`);
326
- }
327
- if (result.added.length > 0) {
328
- console.log(` New: ${result.added.length} files`);
329
- }
330
- console.log('');
331
-
332
- // Print per-IDE next steps
333
- console.log('── Next steps ──');
334
- console.log(' Framework files updated. Start a fresh chat for changes to take effect:');
335
- console.log(' /cadet');
336
- console.log('');
337
- }
1
+ import { createWriteStream, mkdirSync, readFileSync, unlinkSync, existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, dirname, relative } from 'node:path';
3
+ import { inflateRawSync } from 'node:zlib';
4
+ import { Readable } from 'node:stream';
5
+
6
+ // ── Constants ────────────────────────────────────────────────────────────────
7
+
8
+ const DEFAULT_API = 'https://api.github.com/repos/naishtech/cadet-agent/releases/latest';
9
+ const USER_AGENT = 'cadet-agent-cli';
10
+
11
+ function resolveApiUrl(override) {
12
+ return override || process.env.CADET_AGENT_RELEASE_URL || DEFAULT_API;
13
+ }
14
+
15
+ function normalizeVersion(v) {
16
+ return (v || '').replace(/^v/, '');
17
+ }
18
+
19
+ function buildHeaders(extra = {}) {
20
+ const headers = {
21
+ 'User-Agent': USER_AGENT,
22
+ ...extra,
23
+ };
24
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
25
+ if (token) {
26
+ headers['Authorization'] = `Bearer ${token}`;
27
+ }
28
+ return headers;
29
+ }
30
+
31
+ // ── ZIP parser (zero-dependency, handles store + deflate) ───────────────────
32
+
33
+ const SIG_EOCD = 0x06054b50;
34
+ const SIG_CD = 0x02014b50;
35
+ const SIG_LFH = 0x04034b50;
36
+
37
+ function read32(buf, off) { return buf.readUInt32LE(off); }
38
+ function read16(buf, off) { return buf.readUInt16LE(off); }
39
+
40
+ function findEocd(buf) {
41
+ // Search backwards from end for EOCD signature (comment is max 65535 bytes)
42
+ const maxStart = Math.max(0, buf.length - 65535 - 22);
43
+ for (let i = buf.length - 22; i >= maxStart; i--) {
44
+ if (read32(buf, i) === SIG_EOCD) return i;
45
+ }
46
+ throw new Error('Not a valid ZIP file: EOCD signature not found');
47
+ }
48
+
49
+ function* centralDirectoryEntries(buf, cdOffset, cdSize) {
50
+ let off = cdOffset;
51
+ const end = cdOffset + cdSize;
52
+ while (off < end) {
53
+ if (read32(buf, off) !== SIG_CD) break;
54
+ const method = read16(buf, off + 10);
55
+ const compressedSize = read32(buf, off + 20);
56
+ const uncompressedSize = read32(buf, off + 24);
57
+ const filenameLen = read16(buf, off + 28);
58
+ const extraLen = read16(buf, off + 30);
59
+ const commentLen = read16(buf, off + 32);
60
+ const localHeaderOff = read32(buf, off + 42);
61
+ const filename = buf.toString('utf-8', off + 46, off + 46 + filenameLen);
62
+
63
+ // Skip directory entries (trailing / in filename, or uncompressedSize == 0 with no method)
64
+ if (!filename.endsWith('/')) {
65
+ yield { filename, method, compressedSize, uncompressedSize, localHeaderOff };
66
+ }
67
+
68
+ off += 46 + filenameLen + extraLen + commentLen;
69
+ }
70
+ }
71
+
72
+ function extractFile(buf, entry, targetDir) {
73
+ const { filename, method, compressedSize, localHeaderOff } = entry;
74
+
75
+ // Read local file header to get filename + extra lengths (they may differ from CD)
76
+ const lfhFilenameLen = read16(buf, localHeaderOff + 26);
77
+ const lfhExtraLen = read16(buf, localHeaderOff + 28);
78
+
79
+ const dataStart = localHeaderOff + 30 + lfhFilenameLen + lfhExtraLen;
80
+ const compressed = buf.subarray(dataStart, dataStart + compressedSize);
81
+
82
+ let data;
83
+ if (method === 0) {
84
+ // Stored — no compression
85
+ data = compressed;
86
+ } else if (method === 8) {
87
+ // Deflate
88
+ data = inflateRawSync(compressed);
89
+ } else {
90
+ throw new Error(
91
+ `Unsupported compression method ${method} for ${filename}.\n` +
92
+ `This zip uses a compression format this CLI doesn't support.\n` +
93
+ `Try downloading cadet-agent.zip manually from:\n` +
94
+ ` https://github.com/naishtech/cadet-agent/releases/latest`
95
+ );
96
+ }
97
+
98
+ const outPath = join(targetDir, filename);
99
+ mkdirSync(dirname(outPath), { recursive: true });
100
+
101
+ return new Promise((resolve, reject) => {
102
+ const rs = Readable.from(data);
103
+ const ws = createWriteStream(outPath);
104
+ rs.pipe(ws);
105
+ ws.on('finish', () => resolve(outPath));
106
+ ws.on('error', (err) => reject(new Error(`Write failed for ${filename}: ${err.message}`)));
107
+ rs.on('error', (err) => reject(new Error(`Read failed for ${filename}: ${err.message}`)));
108
+ });
109
+ }
110
+
111
+ async function extractZip(buf, targetDir) {
112
+ const eocdOff = findEocd(buf);
113
+ const cdSize = read32(buf, eocdOff + 12);
114
+ const cdOff = read32(buf, eocdOff + 16);
115
+
116
+ // ZIP64 detection — 0xFFFFFFFF in 32-bit fields means real values are in ZIP64 extra records
117
+ if (cdOff === 0xFFFFFFFF || cdSize === 0xFFFFFFFF) {
118
+ throw new Error(
119
+ 'This zip uses ZIP64 format, which is not supported.\n' +
120
+ 'Try downloading cadet-agent.zip manually from:\n' +
121
+ ' https://github.com/naishtech/cadet-agent/releases/latest'
122
+ );
123
+ }
124
+
125
+ const paths = [];
126
+ for (const entry of centralDirectoryEntries(buf, cdOff, cdSize)) {
127
+ const outPath = await extractFile(buf, entry, targetDir);
128
+ paths.push(outPath);
129
+ }
130
+ return paths;
131
+ }
132
+
133
+ // ── GitHub release download ─────────────────────────────────────────────────
134
+
135
+ async function fetchLatestRelease(apiUrl) {
136
+ const url = resolveApiUrl(apiUrl);
137
+ console.log('🔍 Fetching latest Cadet-Agent release...');
138
+
139
+ const res = await fetch(url, {
140
+ headers: buildHeaders({ 'Accept': 'application/vnd.github+json' }),
141
+ });
142
+
143
+ if (!res.ok) {
144
+ if (res.status === 403 || res.status === 429) {
145
+ throw new Error(
146
+ `GitHub API rate-limited (${res.status}). ` +
147
+ 'Set GITHUB_TOKEN or GH_TOKEN env var for authenticated requests, or try again later.'
148
+ );
149
+ }
150
+ throw new Error(`GitHub API returned ${res.status}: ${res.statusText}`);
151
+ }
152
+
153
+ return res.json();
154
+ }
155
+
156
+ function findZipAsset(release) {
157
+ const asset = release.assets?.find(a => a.name === 'cadet-agent.zip');
158
+ if (!asset) {
159
+ throw new Error(
160
+ `Release ${release.tag_name} does not contain cadet-agent.zip.\n` +
161
+ `Available assets: ${(release.assets || []).map(a => a.name).join(', ') || 'none'}`
162
+ );
163
+ }
164
+ return asset;
165
+ }
166
+
167
+ async function downloadZip(url) {
168
+ console.log('⬇️ Downloading cadet-agent.zip...');
169
+
170
+ const res = await fetch(url, {
171
+ headers: buildHeaders({ 'Accept': 'application/octet-stream' }),
172
+ });
173
+
174
+ if (!res.ok) {
175
+ throw new Error(`Download failed: ${res.status} ${res.statusText}`);
176
+ }
177
+
178
+ const contentLength = res.headers.get('content-length');
179
+ const total = contentLength ? parseInt(contentLength, 10) : 0;
180
+
181
+ // Stream to buffer with progress
182
+ const chunks = [];
183
+ let downloaded = 0;
184
+ const reader = res.body.getReader();
185
+
186
+ while (true) {
187
+ const { done, value } = await reader.read();
188
+ if (done) break;
189
+ chunks.push(value);
190
+ downloaded += value.length;
191
+ if (total > 0) {
192
+ const pct = Math.round((downloaded / total) * 100);
193
+ process.stdout.write(`\r ${pct}% (${(downloaded / 1024).toFixed(0)} KB / ${(total / 1024).toFixed(0)} KB)`);
194
+ }
195
+ }
196
+ if (total > 0) process.stdout.write('\n');
197
+
198
+ return Buffer.concat(chunks);
199
+ }
200
+
201
+ // ── Public install entry ────────────────────────────────────────────────────
202
+
203
+ export async function install(targetDir, opts = {}) {
204
+ console.log(`📦 Cadet-Agent — installing to ${targetDir}\n`);
205
+
206
+ // 1. Fetch release metadata
207
+ const release = await fetchLatestRelease(opts.sourceUrl);
208
+ const releaseVersion = normalizeVersion(release.tag_name);
209
+ console.log(` Latest: v${releaseVersion} (published ${release.published_at})\n`);
210
+
211
+ // 2. Find zip asset
212
+ const asset = findZipAsset(release);
213
+
214
+ // 3. Download
215
+ const zipBuf = await downloadZip(asset.browser_download_url);
216
+ console.log(` Downloaded ${(zipBuf.length / 1024).toFixed(0)} KB\n`);
217
+
218
+ // 4. Extract
219
+ console.log('📂 Extracting...');
220
+ const extracted = await extractZip(zipBuf, targetDir);
221
+
222
+ // 5. Report
223
+ console.log(`\n✅ Cadet-Agent v${releaseVersion} installed! Extracted ${extracted.length} files.\n`);
224
+
225
+ // Print per-IDE next steps
226
+ console.log('── Next steps ──');
227
+ console.log(' GitHub Copilot:');
228
+ console.log(' Select "Cadet Agent" from the agent picker in Copilot Chat');
229
+ console.log(' Cursor:');
230
+ console.log(' Already active .cursor\\rules\\cadet-agent.md loads automatically');
231
+ console.log(' Continue:');
232
+ console.log(' Already active .continue\\rules\\cadet-agent.md loads automatically');
233
+ console.log(' Claude Code:');
234
+ console.log(' Already active — .claude\\skills\\cadet-agent.md loads as a project skill');
235
+ console.log('');
236
+ }
237
+
238
+ // ── Manifest-aware extraction ───────────────────────────────────────────────
239
+
240
+ function matchesPreservedPath(filename, preservedPaths) {
241
+ // Normalize: strip leading dot (zip paths like ".cadet/agent/policies/...")
242
+ const normalized = filename.replace(/^\.?\/?/, '');
243
+ for (const preserved of preservedPaths) {
244
+ const p = preserved.replace(/^\.?\/?/, '');
245
+ if (normalized === p || normalized.startsWith(p + '/') || normalized.startsWith(p + '\\')) {
246
+ return true;
247
+ }
248
+ }
249
+ return false;
250
+ }
251
+
252
+ function matchesManagedPath(filename, managedPaths) {
253
+ const normalized = filename.replace(/^\.?\/?/, '').replace(/\\/g, '/');
254
+ for (const m of managedPaths) {
255
+ const mn = m.replace(/^\.?\/?/, '').replace(/\\/g, '/');
256
+ if (normalized === mn || normalized.startsWith(mn + '/')) {
257
+ return true;
258
+ }
259
+ }
260
+ return false;
261
+ }
262
+
263
+ function walkDir(dir, fn) {
264
+ const entries = readdirSync(dir, { withFileTypes: true });
265
+ for (const entry of entries) {
266
+ const fullPath = join(dir, entry.name);
267
+ if (entry.isDirectory()) {
268
+ walkDir(fullPath, fn);
269
+ } else {
270
+ fn(fullPath, relative(dir, fullPath));
271
+ }
272
+ }
273
+ }
274
+
275
+ function deleteObsoleteManagedFiles(targetDir, managedPaths, zipFilenames) {
276
+ const normalizedZip = new Set(
277
+ zipFilenames.map(f => f.replace(/^\.\//, '').replace(/\\/g, '/'))
278
+ );
279
+ const deleted = [];
280
+
281
+ for (const managed of managedPaths) {
282
+ const mn = managed.replace(/^\.\//, '').replace(/\\/g, '/');
283
+ const managedPath = join(targetDir, managed.replace(/^\.\//, ''));
284
+ const stat = (() => { try { return statSync(managedPath); } catch { return null; } })();
285
+ if (!stat) continue;
286
+
287
+ if (stat.isFile()) {
288
+ // Single-file managed path — check if it's in the zip
289
+ if (!normalizedZip.has(mn)) continue;
290
+ } else if (stat.isDirectory()) {
291
+ walkDir(managedPath, (filePath, rel) => {
292
+ const relNorm = rel.replace(/\\/g, '/');
293
+ const fullRel = mn + '/' + relNorm;
294
+ if (!normalizedZip.has(fullRel)) {
295
+ try {
296
+ unlinkSync(filePath);
297
+ deleted.push(filePath);
298
+ } catch {
299
+ // File may already be gone or locked — skip
300
+ }
301
+ }
302
+ });
303
+ }
304
+ }
305
+ return deleted;
306
+ }
307
+
308
+ async function extractZipWithManifest(buf, targetDir, { preserved, managed }) {
309
+ const eocdOff = findEocd(buf);
310
+ const cdSize = read32(buf, eocdOff + 12);
311
+ const cdOff = read32(buf, eocdOff + 16);
312
+
313
+ if (cdOff === 0xFFFFFFFF || cdSize === 0xFFFFFFFF) {
314
+ throw new Error(
315
+ 'This zip uses ZIP64 format, which is not supported.\n' +
316
+ 'Try downloading cadet-agent.zip manually from:\n' +
317
+ ' https://github.com/naishtech/cadet-agent/releases/latest'
318
+ );
319
+ }
320
+
321
+ const updated = [];
322
+ const preserved_list = [];
323
+ const added = [];
324
+ const zipFilenames = [];
325
+
326
+ for (const entry of centralDirectoryEntries(buf, cdOff, cdSize)) {
327
+ zipFilenames.push(entry.filename);
328
+ if (matchesPreservedPath(entry.filename, preserved)) {
329
+ preserved_list.push(entry.filename);
330
+ continue;
331
+ }
332
+ const outPath = await extractFile(buf, entry, targetDir);
333
+ if (matchesManagedPath(entry.filename, managed)) {
334
+ updated.push(outPath);
335
+ } else {
336
+ added.push(outPath);
337
+ }
338
+ }
339
+
340
+ // Delete obsolete managed files no longer in the zip (renamed/removed managed paths)
341
+ const deleted = deleteObsoleteManagedFiles(targetDir, managed, zipFilenames);
342
+
343
+ return { updated, preserved: preserved_list, added, deleted, zipFilenames };
344
+ }
345
+
346
+ // ── Removed-managed-path cleanup ─────────────────────────────────────────────
347
+
348
+ export function findManagedPathsInZip(buf) {
349
+ try {
350
+ const eocdOff = findEocd(buf);
351
+ const cdSize = read32(buf, eocdOff + 12);
352
+ const cdOff = read32(buf, eocdOff + 16);
353
+
354
+ for (const entry of centralDirectoryEntries(buf, cdOff, cdSize)) {
355
+ const fn = entry.filename.replace(/^\.\//, '').replace(/\\/g, '/');
356
+ if (fn === '.cadet/agent/core/FrameworkManifest.json') {
357
+ // Extract just this one entry to read managedPaths
358
+ const lfhFilenameLen = read16(buf, entry.localHeaderOff + 26);
359
+ const lfhExtraLen = read16(buf, entry.localHeaderOff + 28);
360
+ const dataStart = entry.localHeaderOff + 30 + lfhFilenameLen + lfhExtraLen;
361
+ const compressed = buf.subarray(dataStart, dataStart + entry.compressedSize);
362
+ let data;
363
+ if (entry.method === 0) data = compressed;
364
+ else if (entry.method === 8) data = inflateRawSync(compressed);
365
+ else break;
366
+ const manifest = JSON.parse(data.toString('utf-8'));
367
+ return manifest.managedPaths || [];
368
+ }
369
+ }
370
+ } catch {
371
+ // Not a valid zip or manifest not found
372
+ }
373
+ return [];
374
+ }
375
+
376
+ export function deleteRemovedManagedPaths(targetDir, oldManaged, newManaged) {
377
+ const newSet = new Set(newManaged.map(p => p.replace(/^\.\//, '').replace(/\\/g, '/')));
378
+ const deleted = [];
379
+
380
+ for (const old of oldManaged) {
381
+ const oldNorm = old.replace(/^\.\//, '').replace(/\\/g, '/');
382
+ if (newSet.has(oldNorm)) continue;
383
+
384
+ // This path was in the old manifest but is absent from the new one — remove it
385
+ const fullPath = join(targetDir, old.replace(/^\.\//, ''));
386
+ const st = (() => { try { return statSync(fullPath); } catch { return null; } })();
387
+ if (!st) continue;
388
+
389
+ if (st.isFile()) {
390
+ try { unlinkSync(fullPath); deleted.push(fullPath); } catch {}
391
+ } else if (st.isDirectory()) {
392
+ walkDir(fullPath, (filePath) => {
393
+ try { unlinkSync(filePath); deleted.push(filePath); } catch {}
394
+ });
395
+ }
396
+ }
397
+ return deleted;
398
+ }
399
+
400
+ // ── Public sync entry ───────────────────────────────────────────────────────
401
+
402
+ export async function sync(targetDir, opts = {}) {
403
+ console.log(`🔄 Cadet-Agent — syncing ${targetDir}\n`);
404
+
405
+ // 1. Read existing manifest
406
+ const manifestPath = join(targetDir, '.cadet', 'agent', 'core', 'FrameworkManifest.json');
407
+ let existingManifest = null;
408
+ let oldVersion = 'none';
409
+ try {
410
+ existingManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
411
+ oldVersion = existingManifest.frameworkVersion || 'unknown';
412
+ console.log(` Existing install: v${normalizeVersion(oldVersion)}`);
413
+ } catch {
414
+ console.log(' No existing install found — performing full install.\n');
415
+ return install(targetDir, opts);
416
+ }
417
+
418
+ // 2. Fetch release metadata
419
+ const release = await fetchLatestRelease(opts.sourceUrl);
420
+ const newVersion = normalizeVersion(release.tag_name);
421
+ const oldVersionNorm = normalizeVersion(oldVersion);
422
+ console.log(` Latest: v${newVersion} (published ${release.published_at})\n`);
423
+
424
+ if (oldVersionNorm === newVersion) {
425
+ console.log(`✅ Already up to date (v${oldVersionNorm}). Nothing to sync.\n`);
426
+ return;
427
+ }
428
+
429
+ // 3. Download
430
+ const asset = findZipAsset(release);
431
+ const zipBuf = await downloadZip(asset.browser_download_url);
432
+ console.log(` Downloaded ${(zipBuf.length / 1024).toFixed(0)} KB\n`);
433
+
434
+ // 4. Extract with manifest awareness
435
+ console.log('📂 Extracting (preserving local policies and plans)...');
436
+ const result = await extractZipWithManifest(zipBuf, targetDir, {
437
+ preserved: existingManifest.preservedPaths || [],
438
+ managed: existingManifest.managedPaths || [],
439
+ });
440
+
441
+ // 4b. Find new managed paths from the zip and delete any old paths that were removed
442
+ const newManagedPaths = findManagedPathsInZip(zipBuf);
443
+ const removedDeleted = deleteRemovedManagedPaths(
444
+ targetDir,
445
+ existingManifest.managedPaths || [],
446
+ newManagedPaths
447
+ );
448
+ if (removedDeleted.length > 0) {
449
+ result.deleted.push(...removedDeleted);
450
+ }
451
+
452
+ // 5. Report
453
+ console.log('');
454
+ console.log(`✅ Cadet-Agent synced: v${oldVersionNorm} → v${newVersion}`);
455
+ console.log(` Updated: ${result.updated.length} files`);
456
+ if (result.preserved.length > 0) {
457
+ console.log(` Preserved: ${result.preserved.length} files (local policies/plans)`);
458
+ }
459
+ if (result.added.length > 0) {
460
+ console.log(` New: ${result.added.length} files`);
461
+ }
462
+ if (result.deleted.length > 0) {
463
+ console.log(` Removed: ${result.deleted.length} files (no longer managed)`);
464
+ }
465
+ console.log('');
466
+
467
+ // Print per-IDE next steps
468
+ console.log('── Next steps ──');
469
+ console.log(' Framework files updated. Start a fresh chat for changes to take effect:');
470
+ console.log(' Select "Cadet Agent" from the agent picker in Copilot Chat');
471
+ console.log('');
472
+ }