osborn 0.9.22 → 0.9.23
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/dist/index.js +129 -51
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -323,8 +323,13 @@ function startApiServer(workingDir, port) {
|
|
|
323
323
|
console.log('[events] SSE client connected');
|
|
324
324
|
return;
|
|
325
325
|
}
|
|
326
|
-
// GET /sessions/export — stream a gzipped tar of ~/.claude/projects/
|
|
327
|
-
//
|
|
326
|
+
// GET /sessions/export — stream a gzipped tar of ~/.claude/projects/ AND
|
|
327
|
+
// ~/.claude/skills/ to the client. Both directories ship in one archive so
|
|
328
|
+
// a sync covers conversations (projects/) and learned skills together —
|
|
329
|
+
// e.g. PostCompact-written `decisions/SKILL.md` and `learned-behaviors/SKILL.md`
|
|
330
|
+
// travel with the user's session data.
|
|
331
|
+
// Optional ?workDir= query param accepted for backwards compat but ignored
|
|
332
|
+
// (full export is always returned).
|
|
328
333
|
if (req.method === 'GET' && url.pathname === '/sessions/export') {
|
|
329
334
|
if (syncToken) {
|
|
330
335
|
const authHeader = req.headers['authorization'] ?? '';
|
|
@@ -336,17 +341,24 @@ function startApiServer(workingDir, port) {
|
|
|
336
341
|
}
|
|
337
342
|
const claudeDir = join(homedir(), '.claude');
|
|
338
343
|
const projectsDir = join(claudeDir, 'projects');
|
|
344
|
+
const skillsDir = join(claudeDir, 'skills');
|
|
339
345
|
const workDir = url.searchParams.get('workDir');
|
|
340
|
-
|
|
346
|
+
void workDir;
|
|
347
|
+
// Collect which top-level dirs exist — tar fails if we list one that doesn't.
|
|
348
|
+
const topLevel = [];
|
|
349
|
+
if (existsSync(projectsDir))
|
|
350
|
+
topLevel.push('projects');
|
|
351
|
+
if (existsSync(skillsDir))
|
|
352
|
+
topLevel.push('skills');
|
|
353
|
+
if (topLevel.length === 0) {
|
|
341
354
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
342
|
-
res.end(JSON.stringify({ error: 'No sessions found' }));
|
|
355
|
+
res.end(JSON.stringify({ error: 'No sessions or skills found' }));
|
|
343
356
|
return;
|
|
344
357
|
}
|
|
345
|
-
const tarArgs = ['-czf', '-', '-C', claudeDir,
|
|
346
|
-
void workDir; // workDir param accepted but full projects/ export is returned
|
|
358
|
+
const tarArgs = ['-czf', '-', '-C', claudeDir, ...topLevel];
|
|
347
359
|
res.writeHead(200, {
|
|
348
360
|
'Content-Type': 'application/gzip',
|
|
349
|
-
'Content-Disposition': 'attachment; filename="claude-
|
|
361
|
+
'Content-Disposition': 'attachment; filename="claude-export.tar.gz"',
|
|
350
362
|
'Access-Control-Allow-Origin': '*',
|
|
351
363
|
});
|
|
352
364
|
// Stream tar output directly to response
|
|
@@ -358,23 +370,39 @@ function startApiServer(workingDir, port) {
|
|
|
358
370
|
return;
|
|
359
371
|
}
|
|
360
372
|
// GET /sessions/manifest — return mtime+size for all .jsonl files per slug (public, no auth)
|
|
361
|
-
// Helper: merge an extracted tar directory into ~/.claude/projects
|
|
373
|
+
// Helper: merge an extracted tar directory into ~/.claude/{projects,skills}/.
|
|
374
|
+
//
|
|
375
|
+
// Behavior:
|
|
362
376
|
// 1. Skip macOS AppleDouble entries (`._*`) that bsdtar emits
|
|
363
|
-
// 2.
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
377
|
+
// 2. Project slugs: apply slug remap when targetWorkDir is supplied so
|
|
378
|
+
// laptop/codespaces sessions land at the destination's slug.
|
|
379
|
+
// 3. Skills: copy each <skillName>/ directory verbatim (no slug remap —
|
|
380
|
+
// skill identity is the directory name, same across all environments).
|
|
381
|
+
// PostCompact-learned skills like 'decisions' and 'learned-behaviors'
|
|
382
|
+
// from a sprite travel through to the destination this way.
|
|
383
|
+
// 4. Per-file mtime-newer-wins resolves collisions in either dir (preserves
|
|
384
|
+
// whichever side has the more-recent version).
|
|
385
|
+
// 5. Byte-exact copy — no content mutation in either projects/ or skills/.
|
|
386
|
+
const mergeExtractedClaudeDir = async (sourceDir, targetWorkDir) => {
|
|
368
387
|
const claudeDir = join(homedir(), '.claude');
|
|
369
388
|
const projectsDir = join(claudeDir, 'projects');
|
|
389
|
+
const skillsDir = join(claudeDir, 'skills');
|
|
370
390
|
mkdirSync(projectsDir, { recursive: true });
|
|
371
|
-
|
|
391
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
392
|
+
// The archive may wrap content in a 'projects' / 'skills' subdir, or be
|
|
393
|
+
// a flat dir of slugs. Detect both.
|
|
372
394
|
const extractedProjects = join(sourceDir, 'projects');
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
395
|
+
const extractedSkills = join(sourceDir, 'skills');
|
|
396
|
+
const hasProjectsWrapper = existsSync(extractedProjects);
|
|
397
|
+
const hasSkillsWrapper = existsSync(extractedSkills);
|
|
398
|
+
// If neither wrapper exists, treat the source dir as a flat slug dir
|
|
399
|
+
// (back-compat with older client tar layouts).
|
|
400
|
+
const effectiveSource = (hasProjectsWrapper || hasSkillsWrapper) ? null : sourceDir;
|
|
401
|
+
// ─── PROJECTS extraction ─────────────────────────────────────────
|
|
402
|
+
const projectsSource = hasProjectsWrapper ? extractedProjects : effectiveSource;
|
|
403
|
+
const sourceSlugs = projectsSource
|
|
404
|
+
? readdirSync(projectsSource).filter(s => !s.startsWith('._') && !s.startsWith('.DS_Store'))
|
|
405
|
+
: [];
|
|
378
406
|
// Build remap table: source-slug → target-slug.
|
|
379
407
|
// Only remaps slugs that differ from the target (no-op if already correct).
|
|
380
408
|
const remapped = {};
|
|
@@ -396,7 +424,7 @@ function startApiServer(workingDir, port) {
|
|
|
396
424
|
const effectiveSlug = remapped[sourceSlug] ?? sourceSlug;
|
|
397
425
|
const destSlug = join(projectsDir, effectiveSlug);
|
|
398
426
|
mkdirSync(destSlug, { recursive: true });
|
|
399
|
-
const sourceSlugPath = join(
|
|
427
|
+
const sourceSlugPath = join(projectsSource, sourceSlug);
|
|
400
428
|
// NO content mutation. Earlier versions rewrote the embedded `"cwd":"..."`
|
|
401
429
|
// field inside JSONL entries to match the destination workspace. That was
|
|
402
430
|
// wrong on two counts:
|
|
@@ -460,27 +488,71 @@ function startApiServer(workingDir, port) {
|
|
|
460
488
|
catch { /* ignore */ }
|
|
461
489
|
}
|
|
462
490
|
}
|
|
463
|
-
|
|
491
|
+
// ─── SKILLS extraction ───────────────────────────────────────────
|
|
492
|
+
// Skill identity is the directory name (`decisions`, `learned-behaviors`,
|
|
493
|
+
// etc.) so there's no slug remap — each <skillName>/ dir copies into
|
|
494
|
+
// ~/.claude/skills/<skillName>/. Mtime-newer-wins handles collisions:
|
|
495
|
+
// - Default skills seeded by Docker entrypoint have the boot mtime
|
|
496
|
+
// - Learned skills from source have whatever mtime they had on origin
|
|
497
|
+
// - Newer side wins per file
|
|
498
|
+
let skillsWritten = 0;
|
|
499
|
+
if (hasSkillsWrapper) {
|
|
500
|
+
const sourceSkillNames = readdirSync(extractedSkills)
|
|
501
|
+
.filter(s => !s.startsWith('._') && !s.startsWith('.DS_Store'));
|
|
502
|
+
for (const skillName of sourceSkillNames) {
|
|
503
|
+
const srcSkillPath = join(extractedSkills, skillName);
|
|
504
|
+
const dstSkillPath = join(skillsDir, skillName);
|
|
505
|
+
mkdirSync(dstSkillPath, { recursive: true });
|
|
506
|
+
// Reuse the same mtime-aware walkAndCopy from the projects loop —
|
|
507
|
+
// it's still in scope from the last iteration. If sourceSlugs was
|
|
508
|
+
// empty, define it inline here. (Define standalone to be safe.)
|
|
509
|
+
const walkSkill = (src, dst) => {
|
|
510
|
+
const entries = readdirSync(src, { withFileTypes: true });
|
|
511
|
+
for (const e of entries) {
|
|
512
|
+
if (e.name.startsWith('._') || e.name === '.DS_Store')
|
|
513
|
+
continue;
|
|
514
|
+
const sp = join(src, e.name);
|
|
515
|
+
const dp = join(dst, e.name);
|
|
516
|
+
if (e.isDirectory()) {
|
|
517
|
+
mkdirSync(dp, { recursive: true });
|
|
518
|
+
walkSkill(sp, dp);
|
|
519
|
+
}
|
|
520
|
+
else if (e.isFile()) {
|
|
521
|
+
let shouldWrite = true;
|
|
522
|
+
try {
|
|
523
|
+
const dstStat = statSync(dp);
|
|
524
|
+
const srcStat = statSync(sp);
|
|
525
|
+
if (dstStat.mtimeMs >= srcStat.mtimeMs)
|
|
526
|
+
shouldWrite = false;
|
|
527
|
+
}
|
|
528
|
+
catch { }
|
|
529
|
+
if (!shouldWrite)
|
|
530
|
+
continue;
|
|
531
|
+
cpSync(sp, dp, { force: true });
|
|
532
|
+
skillsWritten++;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
walkSkill(srcSkillPath, dstSkillPath);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return { filesWritten, remapped, skillsWritten };
|
|
464
540
|
};
|
|
465
541
|
if (req.method === 'GET' && url.pathname === '/sessions/manifest') {
|
|
466
|
-
// Walks
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
// silently because Claude couldn't find the referenced agent_id transcripts.
|
|
476
|
-
const claudeDir = join(homedir(), '.claude', 'projects');
|
|
477
|
-
const slugMap = {};
|
|
478
|
-
const walkSlug = (slugDir) => {
|
|
542
|
+
// Walks BOTH ~/.claude/projects/ AND ~/.claude/skills/ — full tree per
|
|
543
|
+
// top-level directory.
|
|
544
|
+
// - projects/<slug>/<...> → session JSONLs, sub-agent transcripts, tool-results, osb/
|
|
545
|
+
// - skills/<skillName>/<...> → SKILL.md + any subfiles
|
|
546
|
+
// Files keyed by path RELATIVE to the slug/skill-name dir so the client
|
|
547
|
+
// can preserve structure when computing diffs. mtime in ms epoch.
|
|
548
|
+
const projectsRoot = join(homedir(), '.claude', 'projects');
|
|
549
|
+
const skillsRoot = join(homedir(), '.claude', 'skills');
|
|
550
|
+
const walkDir = (dir) => {
|
|
479
551
|
const files = {};
|
|
480
|
-
const walk = (
|
|
552
|
+
const walk = (curr, relPrefix) => {
|
|
481
553
|
let entries;
|
|
482
554
|
try {
|
|
483
|
-
entries = readdirSync(
|
|
555
|
+
entries = readdirSync(curr, { withFileTypes: true });
|
|
484
556
|
}
|
|
485
557
|
catch {
|
|
486
558
|
return;
|
|
@@ -488,11 +560,10 @@ function startApiServer(workingDir, port) {
|
|
|
488
560
|
for (const e of entries) {
|
|
489
561
|
if (e.name.startsWith('._') || e.name === '.DS_Store')
|
|
490
562
|
continue;
|
|
491
|
-
const sub = join(
|
|
563
|
+
const sub = join(curr, e.name);
|
|
492
564
|
const rel = relPrefix ? `${relPrefix}/${e.name}` : e.name;
|
|
493
|
-
if (e.isDirectory())
|
|
565
|
+
if (e.isDirectory())
|
|
494
566
|
walk(sub, rel);
|
|
495
|
-
}
|
|
496
567
|
else if (e.isFile()) {
|
|
497
568
|
try {
|
|
498
569
|
const st = statSync(sub);
|
|
@@ -502,22 +573,29 @@ function startApiServer(workingDir, port) {
|
|
|
502
573
|
}
|
|
503
574
|
}
|
|
504
575
|
};
|
|
505
|
-
walk(
|
|
576
|
+
walk(dir, '');
|
|
506
577
|
return files;
|
|
507
578
|
};
|
|
579
|
+
const slugMap = {};
|
|
508
580
|
try {
|
|
509
|
-
const
|
|
581
|
+
for (const slug of readdirSync(projectsRoot, { withFileTypes: true })
|
|
510
582
|
.filter(d => d.isDirectory() && !d.name.startsWith('._'))
|
|
511
|
-
.map(d => d.name)
|
|
512
|
-
|
|
513
|
-
slugMap[slug] = { files: walkSlug(join(claudeDir, slug)) };
|
|
583
|
+
.map(d => d.name)) {
|
|
584
|
+
slugMap[slug] = { files: walkDir(join(projectsRoot, slug)) };
|
|
514
585
|
}
|
|
515
586
|
}
|
|
516
|
-
catch {
|
|
517
|
-
|
|
587
|
+
catch { /* projects dir doesn't exist yet — leave empty */ }
|
|
588
|
+
const skillsMap = {};
|
|
589
|
+
try {
|
|
590
|
+
for (const name of readdirSync(skillsRoot, { withFileTypes: true })
|
|
591
|
+
.filter(d => d.isDirectory() && !d.name.startsWith('._'))
|
|
592
|
+
.map(d => d.name)) {
|
|
593
|
+
skillsMap[name] = { files: walkDir(join(skillsRoot, name)) };
|
|
594
|
+
}
|
|
518
595
|
}
|
|
596
|
+
catch { /* skills dir doesn't exist yet — leave empty */ }
|
|
519
597
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
520
|
-
res.end(JSON.stringify({ slugs: slugMap }));
|
|
598
|
+
res.end(JSON.stringify({ slugs: slugMap, skills: skillsMap }));
|
|
521
599
|
return;
|
|
522
600
|
}
|
|
523
601
|
// POST /sessions/import — accept a gzipped tar and extract into ~/.claude/projects/
|
|
@@ -574,9 +652,9 @@ function startApiServer(workingDir, port) {
|
|
|
574
652
|
res.end(JSON.stringify({ error: 'tar extraction failed', code }));
|
|
575
653
|
return;
|
|
576
654
|
}
|
|
577
|
-
const { filesWritten, remapped } = await
|
|
655
|
+
const { filesWritten, remapped, skillsWritten } = await mergeExtractedClaudeDir(tmpDir, targetWorkDir ?? undefined);
|
|
578
656
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
579
|
-
res.end(JSON.stringify({ ok: true, filesWritten, remapped }));
|
|
657
|
+
res.end(JSON.stringify({ ok: true, filesWritten, remapped, skillsWritten }));
|
|
580
658
|
}
|
|
581
659
|
catch (err) {
|
|
582
660
|
console.error('[import] merge error:', err);
|
|
@@ -690,9 +768,9 @@ function startApiServer(workingDir, port) {
|
|
|
690
768
|
res.end(JSON.stringify({ error: 'tar extraction failed', code }));
|
|
691
769
|
return;
|
|
692
770
|
}
|
|
693
|
-
const { filesWritten, remapped } = await
|
|
771
|
+
const { filesWritten, remapped, skillsWritten } = await mergeExtractedClaudeDir(tmpExtractDir, targetWorkDir);
|
|
694
772
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
695
|
-
res.end(JSON.stringify({ ok: true, filesWritten, remapped }));
|
|
773
|
+
res.end(JSON.stringify({ ok: true, filesWritten, remapped, skillsWritten }));
|
|
696
774
|
}
|
|
697
775
|
catch (err) {
|
|
698
776
|
console.error('[import-finalize] merge error:', err);
|