@orderful/droid 0.18.0 → 0.19.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,163 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { getToolMigrations } from './migrations';
6
+
7
+ describe('migrations', () => {
8
+ let testDir: string;
9
+
10
+ beforeEach(() => {
11
+ testDir = join(tmpdir(), `droid-migration-test-${Date.now()}`);
12
+ mkdirSync(testDir, { recursive: true });
13
+ });
14
+
15
+ afterEach(() => {
16
+ if (existsSync(testDir)) {
17
+ rmSync(testDir, { recursive: true });
18
+ }
19
+ });
20
+
21
+ describe('getToolMigrations', () => {
22
+ it('should return migrations for known tools', () => {
23
+ const brainMigrations = getToolMigrations('brain');
24
+ expect(brainMigrations.length).toBeGreaterThan(0);
25
+ expect(brainMigrations[0].version).toBe('0.2.3');
26
+ });
27
+
28
+ it('should return empty array for unknown tools', () => {
29
+ const migrations = getToolMigrations('unknown-tool');
30
+ expect(migrations).toEqual([]);
31
+ });
32
+
33
+ it('should have migrations for all renamed tools', () => {
34
+ const tools = ['brain', 'comments', 'project', 'coach'];
35
+ for (const tool of tools) {
36
+ const migrations = getToolMigrations(tool);
37
+ expect(migrations.length).toBeGreaterThan(0);
38
+ }
39
+ });
40
+ });
41
+
42
+ describe('config directory migration', () => {
43
+ it('should move old prefixed directory to unprefixed', () => {
44
+ // Setup: create old prefixed directory with config
45
+ const skillsDir = join(testDir, 'skills');
46
+ const oldDir = join(skillsDir, 'droid-brain');
47
+ const newDir = join(skillsDir, 'brain');
48
+
49
+ mkdirSync(oldDir, { recursive: true });
50
+ writeFileSync(join(oldDir, 'overrides.yaml'), 'brain_dir: /test/path\n');
51
+
52
+ // Get the migration and run it
53
+ const migrations = getToolMigrations('brain');
54
+ const migration = migrations[0];
55
+ migration.up(testDir);
56
+
57
+ // Verify
58
+ expect(existsSync(oldDir)).toBe(false);
59
+ expect(existsSync(newDir)).toBe(true);
60
+ expect(readFileSync(join(newDir, 'overrides.yaml'), 'utf-8')).toBe('brain_dir: /test/path\n');
61
+ });
62
+
63
+ it('should be idempotent - no error if old directory already moved', () => {
64
+ // Setup: only new directory exists (already migrated)
65
+ const skillsDir = join(testDir, 'skills');
66
+ const newDir = join(skillsDir, 'brain');
67
+
68
+ mkdirSync(newDir, { recursive: true });
69
+ writeFileSync(join(newDir, 'overrides.yaml'), 'brain_dir: /test/path\n');
70
+
71
+ // Get the migration and run it - should not throw
72
+ const migrations = getToolMigrations('brain');
73
+ const migration = migrations[0];
74
+
75
+ expect(() => migration.up(testDir)).not.toThrow();
76
+ expect(existsSync(newDir)).toBe(true);
77
+ });
78
+
79
+ it('should handle collision by removing old directory', () => {
80
+ // Setup: both old and new directories exist
81
+ const skillsDir = join(testDir, 'skills');
82
+ const oldDir = join(skillsDir, 'droid-brain');
83
+ const newDir = join(skillsDir, 'brain');
84
+
85
+ mkdirSync(oldDir, { recursive: true });
86
+ mkdirSync(newDir, { recursive: true });
87
+ writeFileSync(join(oldDir, 'overrides.yaml'), 'old: true\n');
88
+ writeFileSync(join(newDir, 'overrides.yaml'), 'new: true\n');
89
+
90
+ // Run migration
91
+ const migrations = getToolMigrations('brain');
92
+ const migration = migrations[0];
93
+ migration.up(testDir);
94
+
95
+ // Old should be removed, new should be preserved
96
+ expect(existsSync(oldDir)).toBe(false);
97
+ expect(existsSync(newDir)).toBe(true);
98
+ expect(readFileSync(join(newDir, 'overrides.yaml'), 'utf-8')).toBe('new: true\n');
99
+ });
100
+
101
+ it('should do nothing if neither directory exists', () => {
102
+ // Setup: empty skills directory
103
+ const skillsDir = join(testDir, 'skills');
104
+ mkdirSync(skillsDir, { recursive: true });
105
+
106
+ // Run migration - should not throw
107
+ const migrations = getToolMigrations('brain');
108
+ const migration = migrations[0];
109
+
110
+ expect(() => migration.up(testDir)).not.toThrow();
111
+ });
112
+
113
+ it('should create parent directory if needed', () => {
114
+ // Setup: old directory exists but skills/ parent doesn't exist for new
115
+ const oldDir = join(testDir, 'skills', 'droid-brain');
116
+ mkdirSync(oldDir, { recursive: true });
117
+ writeFileSync(join(oldDir, 'overrides.yaml'), 'test: true\n');
118
+
119
+ // Run migration
120
+ const migrations = getToolMigrations('brain');
121
+ const migration = migrations[0];
122
+ migration.up(testDir);
123
+
124
+ // Should have moved successfully
125
+ const newDir = join(testDir, 'skills', 'brain');
126
+ expect(existsSync(newDir)).toBe(true);
127
+ expect(existsSync(oldDir)).toBe(false);
128
+ });
129
+ });
130
+
131
+ describe('migration versions', () => {
132
+ it('brain migration should be version 0.2.3', () => {
133
+ const migrations = getToolMigrations('brain');
134
+ expect(migrations[0].version).toBe('0.2.3');
135
+ });
136
+
137
+ it('comments migration should be version 0.2.6', () => {
138
+ const migrations = getToolMigrations('comments');
139
+ expect(migrations[0].version).toBe('0.2.6');
140
+ });
141
+
142
+ it('project migration should be version 0.1.5', () => {
143
+ const migrations = getToolMigrations('project');
144
+ expect(migrations[0].version).toBe('0.1.5');
145
+ });
146
+
147
+ it('coach migration should be version 0.1.3', () => {
148
+ const migrations = getToolMigrations('coach');
149
+ expect(migrations[0].version).toBe('0.1.3');
150
+ });
151
+ });
152
+
153
+ describe('migration descriptions', () => {
154
+ it('should have descriptive migration descriptions', () => {
155
+ const tools = ['brain', 'comments', 'project', 'coach'];
156
+ for (const tool of tools) {
157
+ const migrations = getToolMigrations(tool);
158
+ expect(migrations[0].description).toContain('config');
159
+ expect(migrations[0].description).toContain('unprefixed');
160
+ }
161
+ });
162
+ });
163
+ });
@@ -0,0 +1,182 @@
1
+ import { existsSync, appendFileSync, mkdirSync, renameSync, rmSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { loadConfig, saveConfig, getConfigDir } from './config';
4
+ import { type Migration } from './types';
5
+ import { compareSemver } from './version';
6
+
7
+ const MIGRATIONS_LOG_FILE = '.migrations.log';
8
+
9
+ /**
10
+ * Get the path to the migrations log file
11
+ */
12
+ function getMigrationsLogPath(): string {
13
+ return join(getConfigDir(), MIGRATIONS_LOG_FILE);
14
+ }
15
+
16
+ /**
17
+ * Log a migration event to the hidden log file
18
+ */
19
+ function logMigration(
20
+ toolName: string,
21
+ fromVersion: string,
22
+ toVersion: string,
23
+ status: 'OK' | 'FAILED',
24
+ error?: string
25
+ ): void {
26
+ const timestamp = new Date().toISOString();
27
+ const logEntry = error
28
+ ? `${timestamp} ${toolName} ${fromVersion} → ${toVersion} ${status}: ${error}\n`
29
+ : `${timestamp} ${toolName} ${fromVersion} → ${toVersion} ${status}\n`;
30
+
31
+ const logPath = getMigrationsLogPath();
32
+ const logDir = dirname(logPath);
33
+
34
+ if (!existsSync(logDir)) {
35
+ mkdirSync(logDir, { recursive: true });
36
+ }
37
+
38
+ appendFileSync(logPath, logEntry);
39
+ }
40
+
41
+ // ============================================================================
42
+ // Tool Migrations Registry
43
+ // Define migrations here, keyed by tool name
44
+ // ============================================================================
45
+
46
+ /**
47
+ * Migration: Move droid-prefixed config directories to unprefixed names
48
+ * e.g., ~/.droid/skills/droid-brain/ → ~/.droid/skills/brain/
49
+ *
50
+ * This fixes a bug where config was saved to prefixed paths but SKILL.md
51
+ * tells Claude to read from unprefixed paths.
52
+ */
53
+ function createConfigDirMigration(skillName: string, version: string): Migration {
54
+ const unprefixedName = skillName.replace(/^droid-/, '');
55
+ return {
56
+ version,
57
+ description: `Move ${skillName} config to unprefixed location`,
58
+ up: (configDir: string) => {
59
+ const oldDir = join(configDir, 'skills', skillName);
60
+ const newDir = join(configDir, 'skills', unprefixedName);
61
+
62
+ // Only migrate if old exists and new doesn't
63
+ if (existsSync(oldDir) && !existsSync(newDir)) {
64
+ // Ensure parent directory exists
65
+ const parentDir = dirname(newDir);
66
+ if (!existsSync(parentDir)) {
67
+ mkdirSync(parentDir, { recursive: true });
68
+ }
69
+ renameSync(oldDir, newDir);
70
+ } else if (existsSync(oldDir) && existsSync(newDir)) {
71
+ // Both exist - remove the old one (new takes precedence)
72
+ rmSync(oldDir, { recursive: true });
73
+ }
74
+ },
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Registry of all tool migrations
80
+ * Key: tool name (e.g., "brain", "comments")
81
+ * Value: array of migrations sorted by version
82
+ *
83
+ * Migration versions are set to the next version after each tool's current version:
84
+ * - brain: 0.2.2 → 0.2.3
85
+ * - comments: 0.2.5 → 0.2.6
86
+ * - project: 0.1.4 → 0.1.5
87
+ * - coach: 0.1.2 → 0.1.3
88
+ */
89
+ const TOOL_MIGRATIONS: Record<string, Migration[]> = {
90
+ brain: [createConfigDirMigration('droid-brain', '0.2.3')],
91
+ comments: [createConfigDirMigration('droid-comments', '0.2.6')],
92
+ project: [createConfigDirMigration('droid-project', '0.1.5')],
93
+ coach: [createConfigDirMigration('droid-coach', '0.1.3')],
94
+ };
95
+
96
+ /**
97
+ * Get migrations for a tool
98
+ */
99
+ export function getToolMigrations(toolName: string): Migration[] {
100
+ return TOOL_MIGRATIONS[toolName] ?? [];
101
+ }
102
+
103
+ /**
104
+ * Get the last migrated version for a tool
105
+ */
106
+ export function getLastMigratedVersion(toolName: string): string {
107
+ const config = loadConfig();
108
+ return config.migrations?.[toolName] ?? '0.0.0';
109
+ }
110
+
111
+ /**
112
+ * Update the last migrated version for a tool
113
+ */
114
+ export function setLastMigratedVersion(toolName: string, version: string): void {
115
+ const config = loadConfig();
116
+ if (!config.migrations) {
117
+ config.migrations = {};
118
+ }
119
+ config.migrations[toolName] = version;
120
+ saveConfig(config);
121
+ }
122
+
123
+ /**
124
+ * Run migrations for a tool between two versions
125
+ * Returns true if all migrations succeeded, false otherwise
126
+ */
127
+ export function runMigrations(
128
+ toolName: string,
129
+ fromVersion: string,
130
+ toVersion: string
131
+ ): { success: boolean; error?: string } {
132
+ const migrations = getToolMigrations(toolName);
133
+
134
+ // Filter migrations that need to run (version > fromVersion && version <= toVersion)
135
+ const pendingMigrations = migrations.filter((m) => {
136
+ const afterFrom = compareSemver(m.version, fromVersion) > 0;
137
+ const beforeOrAtTo = compareSemver(m.version, toVersion) <= 0;
138
+ return afterFrom && beforeOrAtTo;
139
+ });
140
+
141
+ if (pendingMigrations.length === 0) {
142
+ // No migrations to run, but still update the version marker
143
+ setLastMigratedVersion(toolName, toVersion);
144
+ return { success: true };
145
+ }
146
+
147
+ const configDir = getConfigDir();
148
+
149
+ for (const migration of pendingMigrations) {
150
+ try {
151
+ migration.up(configDir);
152
+ logMigration(toolName, fromVersion, migration.version, 'OK');
153
+ } catch (error) {
154
+ const errorMessage = error instanceof Error ? error.message : String(error);
155
+ logMigration(toolName, fromVersion, migration.version, 'FAILED', errorMessage);
156
+ // Don't update version marker on failure - will retry next time
157
+ return { success: false, error: `Migration ${migration.version} failed: ${errorMessage}` };
158
+ }
159
+ }
160
+
161
+ // All migrations succeeded, update version marker
162
+ setLastMigratedVersion(toolName, toVersion);
163
+ return { success: true };
164
+ }
165
+
166
+ /**
167
+ * Run migrations for a tool after install/update
168
+ * Call this after installSkill() completes
169
+ */
170
+ export function runToolMigrations(
171
+ toolName: string,
172
+ installedVersion: string
173
+ ): { success: boolean; error?: string } {
174
+ const lastMigrated = getLastMigratedVersion(toolName);
175
+
176
+ // Only run if the installed version is newer than last migrated
177
+ if (compareSemver(installedVersion, lastMigrated) <= 0) {
178
+ return { success: true };
179
+ }
180
+
181
+ return runMigrations(toolName, lastMigrated, installedVersion);
182
+ }
@@ -86,7 +86,9 @@ export async function promptForSkillConfig(
86
86
 
87
87
  if (Object.keys(overrides).length > 0) {
88
88
  saveSkillOverrides(skillName, overrides);
89
- console.log(chalk.green(`\n✓ Configuration saved to ~/.droid/skills/${skillName}/overrides.yaml`));
89
+ // Strip droid- prefix for display since that's where the file actually goes
90
+ const displayName = skillName.replace(/^droid-/, '');
91
+ console.log(chalk.green(`\n✓ Configuration saved to ~/.droid/skills/${displayName}/overrides.yaml`));
90
92
  } else {
91
93
  console.log(chalk.gray('\nNo custom configuration set (using defaults).'));
92
94
  }
package/src/lib/skills.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'fs';
2
- import { join, dirname } from 'path';
2
+ import { join, dirname, basename } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
  import YAML from 'yaml';
5
5
  import { loadConfig, saveConfig } from './config';
@@ -7,6 +7,7 @@ import { Platform, SkillStatus, type SkillManifest, type InstalledSkill, getPlat
7
7
  import { getInstalledAgentsDir, installAgentFromPath, uninstallAgent, isAgentInstalled } from './agents';
8
8
  import { getSkillsPath, getCommandsPath, getConfigPath } from './platforms';
9
9
  import { loadToolManifest } from './tools';
10
+ import { runToolMigrations } from './migrations';
10
11
 
11
12
  // Marker comments for CLAUDE.md skill registration
12
13
  const DROID_SKILLS_START = '<!-- droid-skills-start -->';
@@ -542,6 +543,14 @@ export function installSkill(skillName: string): { success: boolean; message: st
542
543
  const installedSkillNames = Object.keys(updatedTools);
543
544
  updatePlatformConfigSkills(config.platform, installedSkillNames);
544
545
 
546
+ // Run tool migrations (keyed by tool name, not skill name)
547
+ const toolName = basename(toolDir);
548
+ const migrationResult = runToolMigrations(toolName, manifest.version);
549
+ if (!migrationResult.success) {
550
+ // Log warning but don't fail the install - migration will retry next time
551
+ console.warn(`Warning: Migration failed for ${toolName}: ${migrationResult.error}`);
552
+ }
553
+
545
554
  return { success: true, message: `Installed ${skillName} v${manifest.version}` };
546
555
  }
547
556
 
package/src/lib/types.ts CHANGED
@@ -54,6 +54,7 @@ export interface DroidConfig {
54
54
  git_username: string;
55
55
  platforms: Record<string, PlatformConfig>;
56
56
  auto_update?: AutoUpdateConfig;
57
+ migrations?: Record<string, string>; // tool name -> last migrated version
57
58
  }
58
59
 
59
60
  // Legacy config structure for migration
@@ -120,6 +121,16 @@ export interface SkillOverrides {
120
121
  [key: string]: string | boolean | number;
121
122
  }
122
123
 
124
+ /**
125
+ * Tool migration function
126
+ * Should be idempotent - safe to run multiple times
127
+ */
128
+ export interface Migration {
129
+ version: string;
130
+ description: string;
131
+ up: (configDir: string) => Promise<void> | void;
132
+ }
133
+
123
134
  /**
124
135
  * Tool manifest structure (TOOL.yaml)
125
136
  */
@@ -1,6 +1,6 @@
1
1
  name: brain
2
2
  description: "Your scratchpad (or brain) - a collaborative space for planning and research. Create docs with /brain plan, /brain research, or /brain review. Use @mentions for async discussion. Docs persist across sessions."
3
- version: 0.2.2
3
+ version: 0.2.3
4
4
  status: beta
5
5
 
6
6
  includes:
@@ -1,6 +1,6 @@
1
1
  name: coach
2
2
  description: "Learning-mode AI assistance - AI as coach, not crutch. Use /coach plan for co-authored planning, /coach scaffold for structure with hints, /coach review for Socratic questions."
3
- version: 0.1.2
3
+ version: 0.1.3
4
4
  status: beta
5
5
 
6
6
  includes:
@@ -1,6 +1,6 @@
1
1
  name: comments
2
2
  description: "Enable inline conversations using @droid/@user markers. Tag @droid to ask the AI, AI responds with @{your-name}. Use /comments check to address markers, /comments cleanup to remove resolved threads. Ideal for code review notes and async collaboration."
3
- version: 0.2.5
3
+ version: 0.2.6
4
4
  status: beta
5
5
 
6
6
  includes:
@@ -1,6 +1,6 @@
1
1
  name: project
2
2
  description: "Manage project context files for persistent AI memory across sessions. Load, update, or create project context before working on multi-session features."
3
- version: 0.1.4
3
+ version: 0.1.5
4
4
  status: beta
5
5
 
6
6
  includes: