kido-workspace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,342 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+ const packageRoot = path.resolve(__dirname, '..');
7
+ const pkg = require('../package.json');
8
+ const resourceKinds = ['skills', 'rules', 'templates'];
9
+ const manifestPath = '.agents/kido-install.json';
10
+ // During legacy preflight only, inspect the old physical tree without following .agents.
11
+ let legacyWorkspace = null;
12
+
13
+ const help = `Usage: kido-workspace <init|upgrade> [--dry-run]
14
+ kido-workspace upgrade [--keep-local <resource-path>] [--dry-run]
15
+
16
+ init Install bundled resources in the current directory.
17
+ upgrade Update installed resources, preserving local customizations.
18
+ --dry-run Show changes without writing files.
19
+ --keep-local <path> Accept a reviewed local resource without overwriting it.
20
+ --version Show the running package version.
21
+
22
+ To use the latest release: npx ${pkg.name}@latest upgrade
23
+ `;
24
+
25
+ const agentBootstrap = `# Workspace agent instructions
26
+
27
+ Read [KIDO workflow](.agents/skills/kido-workflow/SKILL.md) for task routing and permission boundaries.
28
+ For onboarding, read [KIDO onboarding](.agents/skills/kido-onboarding/SKILL.md).
29
+
30
+ This directory is the shared workspace root. Application components belong under packages/ after the user chooses move or symlink placement during onboarding.
31
+ Project context belongs under context/, including TypeSpec API contracts under context/contracts/.
32
+ Preserve existing policies and source repositories. Do not read environment files or run project scripts, commit, push, or deploy without matching authorization.
33
+ `;
34
+ const claudeBootstrap = `# Workspace instructions
35
+
36
+ Read [AGENTS.md](AGENTS.md) when present and follow applicable project instructions.
37
+ Shared skills, rules and templates live under .agents/.
38
+ For task routing, read [KIDO workflow](.agents/skills/kido-workflow/SKILL.md).
39
+ For onboarding, read [KIDO onboarding](.agents/skills/kido-onboarding/SKILL.md).
40
+ `;
41
+
42
+ function stat(file) {
43
+ try { return fs.lstatSync(file); }
44
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
45
+ }
46
+
47
+ function digest(contents) {
48
+ return crypto.createHash('sha256').update(contents).digest('hex');
49
+ }
50
+
51
+ // Inspect every ancestor with lstat; never follow a workspace symlink for a write.
52
+ function safePath(root, relative) {
53
+ if (root === legacyWorkspace) relative = relative.replace(/^\.agents(?=\/|$)/, 'agents');
54
+ const parts = relative.split('/');
55
+ if (parts.some((part) => !part || part === '.' || part === '..' || part.includes('\\') || part.includes(':'))) {
56
+ throw new Error(`Invalid resource path: ${relative}`);
57
+ }
58
+ let current = root;
59
+ for (let index = 0; index < parts.length; index++) {
60
+ current = path.join(current, parts[index]);
61
+ const info = stat(current);
62
+ if (info?.isSymbolicLink()) throw new Error(`Refusing a symlink destination: ${relative}`);
63
+ if (info && index < parts.length - 1 && !info.isDirectory()) {
64
+ throw new Error(`Destination parent is not a directory: ${relative}`);
65
+ }
66
+ }
67
+ return current;
68
+ }
69
+
70
+ function isResource(relative) {
71
+ return resourceKinds.some((kind) => relative.startsWith(`.agents/${kind}/`)) &&
72
+ !relative.split('/').some((part) => /^\.env(?:\.|$)/i.test(part));
73
+ }
74
+
75
+ function readBundle() {
76
+ const resources = new Map();
77
+ function walk(source, destination) {
78
+ for (const entry of fs.readdirSync(source, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
79
+ const relative = `${destination}/${entry.name}`;
80
+ const file = path.join(source, entry.name);
81
+ if (!isResource(relative)) throw new Error(`Unsupported bundled resource: ${relative}`);
82
+ if (entry.isSymbolicLink()) throw new Error(`Bundled resources must not contain symlinks: ${relative}`);
83
+ if (entry.isDirectory()) walk(file, relative);
84
+ else if (entry.isFile()) {
85
+ const contents = fs.readFileSync(file);
86
+ resources.set(relative, { contents, hash: digest(contents) });
87
+ } else throw new Error(`Unsupported bundled resource: ${relative}`);
88
+ }
89
+ }
90
+ for (const kind of resourceKinds) {
91
+ const source = path.join(packageRoot, '.agents', kind);
92
+ if (!stat(source)?.isDirectory() || stat(source).isSymbolicLink()) {
93
+ throw new Error(`Missing bundled resources: .agents/${kind}`);
94
+ }
95
+ walk(source, `.agents/${kind}`);
96
+ }
97
+ return resources;
98
+ }
99
+
100
+ function directory(root, relative, operations) {
101
+ const file = safePath(root, relative);
102
+ const info = stat(file);
103
+ if (info && !info.isDirectory()) throw new Error(`Expected a directory: ${relative}`);
104
+ if (!info && !operations.some((operation) => operation.type === 'mkdir' && operation.relative === relative)) {
105
+ operations.push({ type: 'mkdir', relative });
106
+ }
107
+ }
108
+
109
+ function link(root, relative, destination, operations) {
110
+ // Validate the parent separately: the final adapter itself is intentionally a symlink.
111
+ const parent = path.posix.dirname(relative);
112
+ if (parent !== '.') directory(root, parent, operations);
113
+ const file = path.join(root, relative);
114
+ const info = stat(file);
115
+ const target = path.relative(path.dirname(file), path.join(root, destination));
116
+ if (info) {
117
+ if (!info.isSymbolicLink() || path.resolve(path.dirname(file), fs.readlinkSync(file)) !== path.join(root, destination)) {
118
+ const oldDestination = destination.replace(/^\.agents\//, 'agents/');
119
+ if (root === legacyWorkspace && info.isSymbolicLink() &&
120
+ path.resolve(path.dirname(file), fs.readlinkSync(file)) === path.join(root, oldDestination)) {
121
+ operations.push({ type: 'relink', relative, target });
122
+ } else throw new Error(`Adapter already exists with different content: ${relative}`);
123
+ }
124
+ } else operations.push({ type: 'link', relative, target });
125
+ }
126
+
127
+ function adapters(root, resources, operations) {
128
+ directory(root, '.claude/skills', operations);
129
+ const skills = new Set([...resources.keys()]
130
+ .filter((relative) => relative.startsWith('.agents/skills/'))
131
+ .map((relative) => relative.split('/')[2]));
132
+ for (const skill of [...skills].sort()) {
133
+ link(root, `.claude/skills/${skill}`, `.agents/skills/${skill}`, operations);
134
+ }
135
+ link(root, '.claude/templates', '.agents/templates', operations);
136
+ }
137
+
138
+ function readManifest(root) {
139
+ const file = safePath(root, manifestPath);
140
+ const info = stat(file);
141
+ if (!info?.isFile()) throw new Error('No supported KIDO installation found. Run init in the workspace root first.');
142
+ const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
143
+ if (manifest.schemaVersion !== 1 || manifest.packageName !== pkg.name ||
144
+ typeof manifest.templateVersion !== 'string' || typeof manifest.createdAt !== 'string' ||
145
+ !manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) {
146
+ throw new Error('Unsupported installation metadata. Legacy installs need a reviewed migration; no files were changed.');
147
+ }
148
+ for (const [relative, hash] of Object.entries(manifest.files)) {
149
+ const normalized = root === legacyWorkspace ? relative.replace(/^agents\//, '.agents/') : relative;
150
+ if (!isResource(normalized) || typeof hash !== 'string' || !/^[a-f0-9]{64}$/.test(hash)) {
151
+ throw new Error(`Invalid installation metadata entry: ${relative}`);
152
+ }
153
+ safePath(root, normalized);
154
+ }
155
+ if (root === legacyWorkspace) {
156
+ if (Object.keys(manifest.files).some((relative) => !relative.startsWith('agents/'))) {
157
+ throw new Error('Legacy metadata contains mixed resource roots; review migration manually.');
158
+ }
159
+ manifest.files = Object.fromEntries(Object.entries(manifest.files).map(([relative, hash]) => [relative.replace(/^agents\//, '.agents/'), hash]));
160
+ manifest.conflicts = (manifest.conflicts || []).map((relative) => relative.replace(/^agents\//, '.agents/'));
161
+ }
162
+ return manifest;
163
+ }
164
+
165
+ function init(root, resources) {
166
+ if (stat(path.join(root, '.agents'))) {
167
+ throw new Error('.agents already exists. Use upgrade for a managed installation; otherwise review the existing layout first.');
168
+ }
169
+ const operations = [];
170
+ directory(root, '.agents', operations);
171
+ if (stat(safePath(root, manifestPath))) throw new Error('KIDO is already initialized. Use upgrade.');
172
+ for (const kind of resourceKinds) {
173
+ const relative = `.agents/${kind}`;
174
+ if (stat(safePath(root, relative))) throw new Error(`Resource directory already exists: ${relative}`);
175
+ directory(root, relative, operations);
176
+ }
177
+ for (const relative of ['context/contracts', 'epics', 'packages']) directory(root, relative, operations);
178
+ for (const [relative, resource] of resources) {
179
+ safePath(root, relative);
180
+ operations.push({ type: 'write', relative, contents: resource.contents });
181
+ }
182
+ for (const [relative, contents] of [['AGENTS.md', agentBootstrap], ['CLAUDE.md', claudeBootstrap]]) {
183
+ // Existing entrypoints, including symlinks, belong to the project and are left alone.
184
+ if (!stat(path.join(root, relative))) operations.push({ type: 'write', relative, contents });
185
+ }
186
+ adapters(root, resources, operations);
187
+ const timestamp = new Date().toISOString();
188
+ const manifest = {
189
+ schemaVersion: 1, packageName: pkg.name, templateVersion: pkg.version,
190
+ createdAt: timestamp, updatedAt: timestamp,
191
+ files: Object.fromEntries([...resources].map(([relative, resource]) => [relative, resource.hash])),
192
+ conflicts: [],
193
+ };
194
+ operations.push({ type: 'manifest', relative: manifestPath, contents: JSON.stringify(manifest, null, 2) + '\n' });
195
+ return { operations, conflicts: [] };
196
+ }
197
+
198
+ function upgrade(root, resources, keepLocal) {
199
+ const previous = readManifest(root);
200
+ const operations = [];
201
+ if (root === legacyWorkspace) {
202
+ operations.push({ type: 'migrate', relative: '.agents' });
203
+ for (const relative of ['AGENTS.md', 'CLAUDE.md']) {
204
+ const file = path.join(root, relative);
205
+ if (!stat(file)?.isFile()) continue;
206
+ const original = fs.readFileSync(file, 'utf8');
207
+ const contents = original.replace(/(?<![\w./-])agents\/(skills|rules|templates|kido-install\.json)/g, '.agents/$1');
208
+ if (contents !== original) operations.push({ type: 'write', relative, contents });
209
+ }
210
+ }
211
+ const conflicts = [];
212
+ const files = { ...previous.files };
213
+ const paths = [...new Set([...Object.keys(files), ...resources.keys()])].sort();
214
+ for (const relative of keepLocal) {
215
+ if (!isResource(relative) || !paths.includes(relative)) {
216
+ throw new Error(`--keep-local requires a managed resource path: ${relative}`);
217
+ }
218
+ safePath(root, relative);
219
+ }
220
+ for (const relative of paths) {
221
+ const file = safePath(root, relative);
222
+ const info = stat(file);
223
+ if (info && !info.isFile()) throw new Error(`Expected a resource file: ${relative}`);
224
+ const current = info ? digest(fs.readFileSync(file)) : undefined;
225
+ const baseline = previous.files[relative];
226
+ const incoming = resources.get(relative);
227
+ if (keepLocal.has(relative)) {
228
+ if (incoming) files[relative] = incoming.hash;
229
+ else delete files[relative];
230
+ continue;
231
+ }
232
+ if (incoming) {
233
+ if (current === incoming.hash) files[relative] = incoming.hash;
234
+ else if (baseline === incoming.hash) {
235
+ // Only local contents changed (including an intentional local deletion).
236
+ // Retain the original baseline so a future upstream change detects the conflict.
237
+ } else if (current === baseline) {
238
+ operations.push({ type: 'write', relative, contents: incoming.contents });
239
+ files[relative] = incoming.hash;
240
+ } else conflicts.push(relative);
241
+ } else if (current === baseline || current === undefined) {
242
+ if (current !== undefined) operations.push({ type: 'remove', relative });
243
+ delete files[relative];
244
+ } else conflicts.push(relative);
245
+ }
246
+ adapters(root, resources, operations);
247
+ const manifest = { ...previous, templateVersion: pkg.version, files, conflicts };
248
+ // No-op upgrades should leave the installation byte-for-byte unchanged.
249
+ if (JSON.stringify(manifest) !== JSON.stringify(previous) || operations.length > 0) {
250
+ manifest.updatedAt = new Date().toISOString();
251
+ operations.push({ type: 'manifest', relative: manifestPath, contents: JSON.stringify(manifest, null, 2) + '\n' });
252
+ }
253
+ return { operations, conflicts };
254
+ }
255
+
256
+ function apply(root, operation) {
257
+ const file = path.join(root, operation.relative);
258
+ if (operation.type === 'migrate') {
259
+ const previous = path.join(root, 'agents');
260
+ if (!stat(file)?.isSymbolicLink() || path.resolve(root, fs.readlinkSync(file)) !== previous ||
261
+ !stat(previous)?.isDirectory() || stat(previous).isSymbolicLink()) {
262
+ throw new Error('Legacy layout changed during migration; stopped.');
263
+ }
264
+ const target = fs.readlinkSync(file);
265
+ fs.unlinkSync(file);
266
+ try { fs.renameSync(previous, file); }
267
+ catch (error) {
268
+ fs.symlinkSync(target, file, process.platform === 'win32' ? 'junction' : 'dir');
269
+ throw error;
270
+ }
271
+ return;
272
+ }
273
+ if (operation.type === 'link' || operation.type === 'relink') {
274
+ if (operation.type === 'relink') fs.unlinkSync(file);
275
+ fs.symlinkSync(operation.target, file, process.platform === 'win32' ? 'junction' : 'dir');
276
+ return;
277
+ }
278
+ safePath(root, operation.relative);
279
+ if (operation.type === 'mkdir') fs.mkdirSync(file, { recursive: true });
280
+ else if (operation.type === 'remove') fs.unlinkSync(file);
281
+ else {
282
+ fs.mkdirSync(path.dirname(file), { recursive: true });
283
+ if (operation.type === 'manifest') {
284
+ const temporary = `${file}.${crypto.randomUUID()}.tmp`;
285
+ try {
286
+ fs.writeFileSync(temporary, operation.contents, { flag: 'wx' });
287
+ fs.renameSync(temporary, file);
288
+ } finally {
289
+ if (stat(temporary)) fs.unlinkSync(temporary);
290
+ }
291
+ } else fs.writeFileSync(file, operation.contents);
292
+ }
293
+ }
294
+
295
+ function run(args) {
296
+ if (args.length === 0 || (args.length === 1 && ['--help', '-h'].includes(args[0]))) {
297
+ console.log(help);
298
+ return 0;
299
+ }
300
+ if (args.length === 1 && ['--version', '-v'].includes(args[0])) {
301
+ console.log(pkg.version);
302
+ return 0;
303
+ }
304
+ const [command, ...flags] = args;
305
+ if (!['init', 'upgrade'].includes(command)) throw new Error(`Unknown command: ${command}.\n${help}`);
306
+ if (flags.length === 1 && ['--help', '-h'].includes(flags[0])) { console.log(help); return 0; }
307
+ let dryRun = false;
308
+ const keepLocal = new Set();
309
+ for (let index = 0; index < flags.length; index++) {
310
+ const flag = flags[index];
311
+ if (flag === '--dry-run' && !dryRun) dryRun = true;
312
+ else if (flag === '--keep-local' && command === 'upgrade' && flags[index + 1] && !flags[index + 1].startsWith('-')) {
313
+ keepLocal.add(flags[++index]);
314
+ } else throw new Error(`Unsupported or incomplete argument: ${flag}.\n${help}`);
315
+ }
316
+ const root = fs.realpathSync(process.cwd());
317
+ const resources = readBundle();
318
+ let result;
319
+ try {
320
+ const adapter = path.join(root, '.agents');
321
+ if (command === 'upgrade' && stat(adapter)?.isSymbolicLink() &&
322
+ path.resolve(root, fs.readlinkSync(adapter)) === path.join(root, 'agents')) {
323
+ legacyWorkspace = root;
324
+ }
325
+ result = command === 'init' ? init(root, resources) : upgrade(root, resources, keepLocal);
326
+ } finally { legacyWorkspace = null; }
327
+ console.log(`${dryRun ? 'Dry run: ' : ''}${command} ${pkg.name}@${pkg.version} in ${root}`);
328
+ for (const operation of result.operations) {
329
+ console.log(` ${operation.type} ${operation.relative}`);
330
+ if (!dryRun) apply(root, operation);
331
+ }
332
+ for (const conflict of result.conflicts) console.error(` conflict (kept local): ${conflict}`);
333
+ if (result.conflicts.length) {
334
+ console.error('Compare conflicts with the bundled resources. After reviewing a local merge, use upgrade --keep-local <path>. Exit code: 2.');
335
+ return 2;
336
+ }
337
+ if (command === 'init') console.log('Next: ask your agent to onboard this workspace.');
338
+ else console.log(result.operations.length ? 'Upgrade complete.' : 'Already up to date.');
339
+ return 0;
340
+ }
341
+
342
+ module.exports = { run };
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "kido-workspace",
3
+ "version": "0.1.0",
4
+ "description": "Initialize and upgrade KIDO skills, rules and templates in a workspace",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "kido-workspace": "bin/kido-workspace.cjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ ".agents/skills/",
13
+ ".agents/rules/",
14
+ ".agents/templates/"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "scripts": {
20
+ "test": "node --test test/*.test.cjs"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "registry": "https://registry.npmjs.org/"
25
+ }
26
+ }