takomi 2.1.26 → 2.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/harness.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import crypto from 'crypto';
4
5
  import pc from 'picocolors';
5
6
 
6
7
  // ─── Cross-Platform Home Directory ───────────────────────────────────────────
@@ -169,33 +170,122 @@ export function printHarnessStatus(detected) {
169
170
  * @param {string} label - Human-readable label for logging
170
171
  * @returns {Promise<number>} - Number of items copied
171
172
  */
172
- export async function syncDirectory(sourcePath, targetPath, label = '') {
173
- if (!targetPath) return 0;
173
+ function sha256(value) {
174
+ return crypto.createHash('sha256').update(value).digest('hex');
175
+ }
176
+
177
+ async function hashPath(targetPath) {
178
+ if (!await fs.pathExists(targetPath)) return null;
179
+ const stat = await fs.stat(targetPath);
180
+ if (stat.isFile()) return sha256(await fs.readFile(targetPath));
181
+
182
+ const entries = [];
183
+ async function walk(current, prefix = '') {
184
+ const names = (await fs.readdir(current)).sort();
185
+ for (const name of names) {
186
+ const full = path.join(current, name);
187
+ const rel = path.join(prefix, name).replace(/\\/g, '/');
188
+ const st = await fs.stat(full);
189
+ if (st.isDirectory()) {
190
+ entries.push(`dir:${rel}`);
191
+ await walk(full, rel);
192
+ } else {
193
+ entries.push(`file:${rel}:${sha256(await fs.readFile(full))}`);
194
+ }
195
+ }
196
+ }
197
+ await walk(targetPath);
198
+ return sha256(entries.join('\n'));
199
+ }
200
+
201
+ function normalizeOwnedMap(value) {
202
+ if (!value || typeof value !== 'object') return {};
203
+ const normalized = {};
204
+ for (const [name, entry] of Object.entries(value)) {
205
+ if (typeof entry === 'string') normalized[name] = { hash: entry };
206
+ else if (entry?.hash) normalized[name] = entry;
207
+ }
208
+ return normalized;
209
+ }
210
+
211
+ /**
212
+ * Syncs a directory to a target path. When ownership is supplied, it also
213
+ * prunes previous Takomi-owned items that are no longer in the source while
214
+ * preserving manual or modified files.
215
+ */
216
+ export async function syncDirectory(sourcePath, targetPath, label = '', options = {}) {
217
+ if (!targetPath) return options.returnDetails ? { copied: 0, pruned: 0, preservedManual: [], preservedModified: [], owned: {} } : 0;
174
218
 
175
219
  try {
176
220
  await fs.ensureDir(targetPath);
177
221
 
178
- const items = await fs.readdir(sourcePath);
179
- let count = 0;
222
+ const items = (await fs.readdir(sourcePath)).sort();
223
+ const itemSet = new Set(items);
224
+ const previousOwned = normalizeOwnedMap(options.owned);
225
+ const nextOwned = {};
226
+ const preservedManual = [];
227
+ const preservedModified = [];
228
+ let copied = 0;
229
+ let pruned = 0;
230
+
231
+ if (options.prune && Object.keys(previousOwned).length > 0) {
232
+ for (const [item, ownedEntry] of Object.entries(previousOwned)) {
233
+ if (itemSet.has(item)) continue;
234
+ const dest = path.join(targetPath, item);
235
+ if (!await fs.pathExists(dest)) continue;
236
+ const currentHash = await hashPath(dest);
237
+ if (currentHash && currentHash === ownedEntry.hash) {
238
+ await fs.remove(dest);
239
+ pruned++;
240
+ } else {
241
+ preservedModified.push(item);
242
+ nextOwned[item] = ownedEntry;
243
+ }
244
+ }
245
+ }
180
246
 
181
247
  for (const item of items) {
182
248
  const src = path.join(sourcePath, item);
183
249
  const dest = path.join(targetPath, item);
250
+ const previous = previousOwned[item];
251
+
252
+ if (await fs.pathExists(dest)) {
253
+ const currentHash = await hashPath(dest);
254
+ if (options.preserveManual && !previous) {
255
+ preservedManual.push(item);
256
+ continue;
257
+ }
258
+ if (previous?.hash && currentHash && currentHash !== previous.hash) {
259
+ preservedModified.push(item);
260
+ nextOwned[item] = previous;
261
+ continue;
262
+ }
263
+ await fs.remove(dest);
264
+ }
184
265
 
185
266
  await fs.copy(src, dest, { overwrite: true });
186
- count++;
267
+ nextOwned[item] = {
268
+ hash: await hashPath(dest),
269
+ targetPath: dest,
270
+ syncedAt: new Date().toISOString(),
271
+ };
272
+ copied++;
187
273
  }
188
274
 
189
275
  if (label) {
190
- console.log(pc.green(` ✔ ${label} (${count} items)`));
276
+ const suffix = pruned ? `, removed ${pruned}` : '';
277
+ console.log(pc.green(` ✔ ${label} (${copied} items${suffix})`));
278
+ if (preservedManual.length) console.log(pc.yellow(` Preserved manual items: ${preservedManual.join(', ')}`));
279
+ if (preservedModified.length) console.log(pc.yellow(` Preserved modified Takomi-owned items: ${preservedModified.join(', ')}`));
191
280
  }
192
281
 
193
- return count;
282
+ const details = { copied, pruned, preservedManual, preservedModified, owned: Object.fromEntries(Object.entries(nextOwned).sort(([a], [b]) => a.localeCompare(b))) };
283
+ return options.returnDetails ? details : copied;
194
284
  } catch (error) {
195
285
  if (label) {
196
286
  console.log(pc.red(` ✗ ${label}: ${error.message}`));
197
287
  }
198
- return 0;
288
+ return options.returnDetails ? { copied: 0, pruned: 0, preservedManual: [], preservedModified: [], owned: {} } : 0;
199
289
  }
200
290
  }
201
291
 
@@ -238,29 +328,55 @@ export async function syncFile(sourceFile, targetPaths, label = '') {
238
328
  * @param {string} storePath - Path to the global store (~/.takomi/)
239
329
  * @returns {Promise<{skills: number, workflows: number, yamls: number}>}
240
330
  */
241
- export async function syncToHarness(harness, storePath) {
242
- const results = { skills: 0, workflows: 0, yamls: 0 };
331
+ export async function syncToHarness(harness, storePath, options = {}) {
332
+ const results = { skills: 0, workflows: 0, yamls: 0, owned: { skills: {}, workflows: {} } };
333
+ const harnessOwned = options.owned?.[harness.id] || {};
334
+ const useOwnership = Boolean(options.useOwnership);
243
335
 
244
336
  console.log(pc.cyan(`\n 📡 Syncing to ${harness.name}...`));
245
337
 
246
338
  // Skills
247
339
  const skillsStore = path.join(storePath, 'skills');
248
340
  if (harness.targets.skills && await fs.pathExists(skillsStore)) {
249
- results.skills = await syncDirectory(
341
+ const skillResult = await syncDirectory(
250
342
  skillsStore,
251
343
  harness.targets.skills,
252
- `Skills → ${harness.name}`
344
+ `Skills → ${harness.name}`,
345
+ {
346
+ owned: harnessOwned.skills,
347
+ preserveManual: useOwnership,
348
+ prune: useOwnership,
349
+ returnDetails: useOwnership,
350
+ },
253
351
  );
352
+ if (useOwnership) {
353
+ results.skills = skillResult.copied;
354
+ results.owned.skills = skillResult.owned;
355
+ } else {
356
+ results.skills = skillResult;
357
+ }
254
358
  }
255
359
 
256
360
  // Workflows
257
361
  const workflowsStore = path.join(storePath, 'workflows');
258
362
  if (harness.targets.workflows && await fs.pathExists(workflowsStore)) {
259
- results.workflows = await syncDirectory(
363
+ const workflowResult = await syncDirectory(
260
364
  workflowsStore,
261
365
  harness.targets.workflows,
262
- `Workflows → ${harness.name}`
366
+ `Workflows → ${harness.name}`,
367
+ {
368
+ owned: harnessOwned.workflows,
369
+ preserveManual: useOwnership,
370
+ prune: useOwnership,
371
+ returnDetails: useOwnership,
372
+ },
263
373
  );
374
+ if (useOwnership) {
375
+ results.workflows = workflowResult.copied;
376
+ results.owned.workflows = workflowResult.owned;
377
+ } else {
378
+ results.workflows = workflowResult;
379
+ }
264
380
  }
265
381
 
266
382
  // YAMLs (KiloCode custom_modes.yaml dual-path)
@@ -283,11 +399,11 @@ export async function syncToHarness(harness, storePath) {
283
399
  * @param {string} storePath - Path to the global store (~/.takomi/)
284
400
  * @returns {Promise<object>} - Summary of sync results
285
401
  */
286
- export async function syncToAllHarnesses(harnesses, storePath) {
402
+ export async function syncToAllHarnesses(harnesses, storePath, options = {}) {
287
403
  const summary = {};
288
404
 
289
405
  for (const harness of harnesses) {
290
- summary[harness.id] = await syncToHarness(harness, storePath);
406
+ summary[harness.id] = await syncToHarness(harness, storePath, options);
291
407
  }
292
408
 
293
409
  return summary;
package/src/pi-harness.js CHANGED
@@ -220,6 +220,9 @@ export async function ensurePiInstalled() {
220
220
  }
221
221
 
222
222
  const attempts = [
223
+ { command: 'npm', args: ['install', '-g', '@earendil-works/pi-coding-agent'] },
224
+ { command: 'npm.cmd', args: ['install', '-g', '@earendil-works/pi-coding-agent'] },
225
+ // Legacy fallback for older environments that still publish under the original namespace.
223
226
  { command: 'npm', args: ['install', '-g', '@mariozechner/pi-coding-agent'] },
224
227
  { command: 'npm.cmd', args: ['install', '-g', '@mariozechner/pi-coding-agent'] },
225
228
  ];
@@ -301,19 +304,23 @@ export async function updatePiManagedPackages() {
301
304
  const pi = await detectPiCommand();
302
305
  if (!pi.installed) return { ok: false, changed: false, report: 'Pi is not installed.' };
303
306
 
307
+ // `pi update` reconciles every package listed in Pi settings, including
308
+ // user-installed npm/git/local packages. Keep this broad so Takomi refresh
309
+ // updates old, new, custom, and optional feature packages without needing to
310
+ // know their names ahead of time.
304
311
  const command = pi.path || 'pi';
305
312
  const result = runCommand(command, ['update']);
306
313
  const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
307
314
  return {
308
315
  ok: result.status === 0,
309
316
  changed: result.status === 0,
310
- report: output || (result.status === 0 ? 'Pi managed packages are up to date.' : 'pi update failed.'),
317
+ report: output || (result.status === 0 ? 'All Pi-managed packages are up to date.' : 'pi update failed.'),
311
318
  };
312
319
  }
313
320
 
314
321
  export function printPiManagedPackageUpdateResult(result) {
315
322
  if (result.ok) {
316
- console.log(pc.green('✔ Updated Pi-managed packages'));
323
+ console.log(pc.green('✔ Updated all Pi-managed packages'));
317
324
  if (result.report) console.log(pc.dim(result.report.split(/\r?\n/).slice(-8).join('\n')));
318
325
  return;
319
326
  }
@@ -342,18 +349,32 @@ export async function inspectPiHarnessEnvironment(cwd = process.cwd()) {
342
349
  };
343
350
  }
344
351
 
352
+ function printFirstRunGuidance(reason) {
353
+ console.log(pc.magenta('\n🎯 Welcome to Takomi\n'));
354
+ if (reason) console.log(pc.yellow(reason));
355
+ console.log(pc.white('\nRecommended first step:'));
356
+ console.log(pc.cyan(' takomi setup pi'));
357
+ console.log(pc.dim(' Set up the Pi-native Takomi harness.'));
358
+ console.log(pc.white('\nOptional setup:'));
359
+ console.log(pc.dim(' takomi setup pi-features Add optional Pi feature packs'));
360
+ console.log(pc.dim(' takomi setup skills Install global Takomi skills'));
361
+ console.log(pc.dim(' takomi setup all Set up Pi + skills'));
362
+ console.log(pc.white('\nDiagnostics and help:'));
363
+ console.log(pc.dim(' takomi doctor Check installation health'));
364
+ console.log(pc.dim(' takomi status Show connected harnesses/toolkit status'));
365
+ console.log(pc.dim(' takomi --help Show all commands\n'));
366
+ }
367
+
345
368
  export async function launchTakomiHarness(cwd = process.cwd()) {
346
369
  const report = await inspectPiHarnessEnvironment(cwd);
347
370
 
348
371
  if (!report.pi.installed) {
349
- console.log(pc.red('Pi is not installed.'));
350
- console.log(pc.dim('Run: takomi install pi'));
372
+ printFirstRunGuidance('Pi is not installed yet.');
351
373
  return 1;
352
374
  }
353
375
 
354
376
  if (!report.installed.runtimeInstalled || !report.installed.subagentsInstalled) {
355
- console.log(pc.red('Takomi Pi harness is not fully installed.'));
356
- console.log(pc.dim('Run: takomi install pi'));
377
+ printFirstRunGuidance('Takomi Pi harness is not fully installed yet.');
357
378
  return 1;
358
379
  }
359
380
 
@@ -0,0 +1,195 @@
1
+ import fs from 'fs-extra';
2
+ import { spawnSync } from 'child_process';
3
+ import prompts from 'prompts';
4
+ import pc from 'picocolors';
5
+ import { detectPiCommand, getPiGlobalTargets } from './pi-harness.js';
6
+
7
+ export const TAKOMI_PI_OPTIONAL_FEATURES = [
8
+ {
9
+ id: 'takomi-interview',
10
+ title: 'Takomi Interview',
11
+ packageSpec: 'npm:@juicesharp/rpiv-ask-user-question',
12
+ npmPackage: '@juicesharp/rpiv-ask-user-question',
13
+ selectedByDefault: true,
14
+ recommended: true,
15
+ description: 'Structured model questions for Genesis/design clarification. Default Takomi add-on.',
16
+ },
17
+ {
18
+ id: 'takomi-todo',
19
+ title: 'Takomi Todo',
20
+ packageSpec: 'npm:@juicesharp/rpiv-todo',
21
+ npmPackage: '@juicesharp/rpiv-todo',
22
+ selectedByDefault: false,
23
+ recommended: false,
24
+ description: 'Live model todo overlay. Optional while it is being tested.',
25
+ },
26
+ {
27
+ id: 'takomi-browser-qa',
28
+ title: 'Takomi Browser QA',
29
+ packageSpec: 'npm:pi-chrome',
30
+ npmPackage: 'pi-chrome',
31
+ selectedByDefault: false,
32
+ recommended: false,
33
+ description: 'Chrome/browser automation for UI QA and authenticated web workflows.',
34
+ },
35
+ {
36
+ id: 'takomi-doc-preview',
37
+ title: 'Takomi Doc Preview',
38
+ packageSpec: 'npm:pi-markdown-preview',
39
+ npmPackage: 'pi-markdown-preview',
40
+ selectedByDefault: false,
41
+ recommended: false,
42
+ description: 'Markdown/LaTeX/browser/PDF previews for Takomi docs and handoffs.',
43
+ },
44
+ ];
45
+
46
+ function runCommand(command, args) {
47
+ return spawnSync(command, args, { stdio: 'pipe', encoding: 'utf8', shell: process.platform === 'win32' });
48
+ }
49
+
50
+ function packageIdentity(packageSpec) {
51
+ if (!packageSpec.startsWith('npm:')) return packageSpec;
52
+ const withoutPrefix = packageSpec.slice(4);
53
+ if (withoutPrefix.startsWith('@')) {
54
+ const parts = withoutPrefix.split('@');
55
+ return `${parts[0]}@${parts[1]}`;
56
+ }
57
+ return withoutPrefix.split('@')[0];
58
+ }
59
+
60
+ function packageEntrySource(entry) {
61
+ if (typeof entry === 'string') return entry;
62
+ if (entry && typeof entry === 'object' && typeof entry.source === 'string') return entry.source;
63
+ return undefined;
64
+ }
65
+
66
+ function packageMatches(entry, packageSpec) {
67
+ const source = packageEntrySource(entry);
68
+ if (!source) return false;
69
+ if (source === packageSpec) return true;
70
+ if (!source.startsWith('npm:') || !packageSpec.startsWith('npm:')) return false;
71
+ return packageIdentity(source) === packageIdentity(packageSpec);
72
+ }
73
+
74
+ async function readGlobalPiSettings() {
75
+ try {
76
+ return await fs.readJson(getPiGlobalTargets().settings);
77
+ } catch {
78
+ return {};
79
+ }
80
+ }
81
+
82
+ export async function getInstalledPiPackageSpecs() {
83
+ const settings = await readGlobalPiSettings();
84
+ return Array.isArray(settings.packages) ? settings.packages : [];
85
+ }
86
+
87
+ export async function isPiPackageInstalled(packageSpec) {
88
+ const packages = await getInstalledPiPackageSpecs();
89
+ return packages.some((entry) => packageMatches(entry, packageSpec));
90
+ }
91
+
92
+ export async function installPiPackage(packageSpec) {
93
+ const pi = await detectPiCommand();
94
+ if (!pi.installed) return { ok: false, changed: false, report: 'Pi is not installed.' };
95
+
96
+ if (await isPiPackageInstalled(packageSpec)) {
97
+ return { ok: true, changed: false, report: `${packageSpec} already listed in Pi settings.` };
98
+ }
99
+
100
+ const command = pi.path || 'pi';
101
+ const result = runCommand(command, ['install', packageSpec]);
102
+ const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
103
+ return {
104
+ ok: result.status === 0,
105
+ changed: result.status === 0,
106
+ report: output || (result.status === 0 ? `Installed ${packageSpec}.` : `pi install ${packageSpec} failed.`),
107
+ };
108
+ }
109
+
110
+ export async function installPiOptionalFeatures(featureIds) {
111
+ const selected = TAKOMI_PI_OPTIONAL_FEATURES.filter((feature) => featureIds.includes(feature.id));
112
+ const results = [];
113
+ for (const feature of selected) {
114
+ const result = await installPiPackage(feature.packageSpec);
115
+ results.push({ feature, ...result });
116
+ }
117
+ return results;
118
+ }
119
+
120
+ export function printPiOptionalFeatureResults(results) {
121
+ if (!results.length) {
122
+ console.log(pc.dim(' Optional Pi features skipped.'));
123
+ return;
124
+ }
125
+
126
+ for (const result of results) {
127
+ const icon = result.ok ? (result.changed ? pc.green('✔') : pc.dim('•')) : pc.yellow('⚠');
128
+ const action = result.ok ? (result.changed ? 'Installed' : 'Already installed') : 'Could not install';
129
+ console.log(`${icon} ${action} ${result.feature.title} (${result.feature.packageSpec})`);
130
+ if (result.report) console.log(pc.dim(result.report.split(/\r?\n/).slice(-4).join('\n')));
131
+ }
132
+ }
133
+
134
+ export async function selectPiOptionalFeatures(options = {}) {
135
+ const { interactive = true } = options;
136
+ if (!interactive) {
137
+ return TAKOMI_PI_OPTIONAL_FEATURES
138
+ .filter((feature) => feature.selectedByDefault)
139
+ .map((feature) => feature.id);
140
+ }
141
+
142
+ const modeResponse = await prompts({
143
+ type: 'select',
144
+ name: 'mode',
145
+ message: 'Optional Pi features to install?',
146
+ choices: [
147
+ { title: 'Recommended defaults', value: 'recommended', description: 'Takomi Interview now; keep other packs optional' },
148
+ { title: 'Select all', value: 'all', description: 'Install every official optional Takomi Pi pack' },
149
+ { title: 'Manual selection', value: 'manual', description: 'Choose individual optional packages' },
150
+ { title: 'Skip optional features', value: 'skip', description: 'Only install the Takomi core harness' },
151
+ ],
152
+ initial: 0,
153
+ });
154
+
155
+ if (!modeResponse.mode || modeResponse.mode === 'skip') return [];
156
+ if (modeResponse.mode === 'all') return TAKOMI_PI_OPTIONAL_FEATURES.map((feature) => feature.id);
157
+ if (modeResponse.mode === 'recommended') {
158
+ return TAKOMI_PI_OPTIONAL_FEATURES
159
+ .filter((feature) => feature.selectedByDefault || feature.recommended)
160
+ .map((feature) => feature.id);
161
+ }
162
+
163
+ const manualResponse = await prompts({
164
+ type: 'multiselect',
165
+ name: 'features',
166
+ message: 'Select optional Takomi Pi feature packs:',
167
+ choices: TAKOMI_PI_OPTIONAL_FEATURES.map((feature) => ({
168
+ title: feature.title,
169
+ value: feature.id,
170
+ selected: feature.selectedByDefault,
171
+ description: `${feature.description} (${feature.packageSpec})`,
172
+ })),
173
+ hint: '- Space to select. Press a to toggle all. Return to submit',
174
+ });
175
+
176
+ return manualResponse.features || [];
177
+ }
178
+
179
+ export async function offerPiOptionalFeatures(options = {}) {
180
+ const featureIds = await selectPiOptionalFeatures(options);
181
+ const results = await installPiOptionalFeatures(featureIds);
182
+ printPiOptionalFeatureResults(results);
183
+ return results;
184
+ }
185
+
186
+ export function renderPiOptionalFeatureCatalog() {
187
+ return TAKOMI_PI_OPTIONAL_FEATURES.map((feature) => ({
188
+ id: feature.id,
189
+ title: feature.title,
190
+ packageSpec: feature.packageSpec,
191
+ selectedByDefault: feature.selectedByDefault,
192
+ recommended: feature.recommended,
193
+ description: feature.description,
194
+ }));
195
+ }
@@ -0,0 +1,27 @@
1
+ try {
2
+ if (process.env.TAKOMI_SUPPRESS_POSTINSTALL === '1') process.exit(0);
3
+ if (process.env.CI === 'true' || process.env.CI === '1') process.exit(0);
4
+
5
+ const lines = [
6
+ '',
7
+ '🎯 Takomi installed.',
8
+ '',
9
+ 'Recommended next steps:',
10
+ ' takomi setup pi Set up the Pi-native Takomi harness',
11
+ ' takomi setup pi-features Add optional Pi feature packs',
12
+ ' takomi setup skills Install global Takomi skills',
13
+ ' takomi setup all Set up Pi + skills',
14
+ '',
15
+ 'Useful commands:',
16
+ ' takomi doctor Check installation health',
17
+ ' takomi status Show connected harnesses/toolkit status',
18
+ ' takomi refresh Update Takomi, Pi assets, skills, and Pi packages',
19
+ ' takomi --help Show all commands',
20
+ '',
21
+ 'Then run `takomi` from inside a project.',
22
+ '',
23
+ ];
24
+ console.log(lines.join('\n'));
25
+ } catch {
26
+ // Postinstall guidance must never break package installation.
27
+ }