From 643a02bff33969ad281243ddef06a9acad326ab0 Mon Sep 17 00:00:00 2001 From: Ricardo Schroeder Canova <220612828+canovars@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:36:20 -0300 Subject: [PATCH 1/2] fix: support marketing-based project layout in skills and runs --- src/runs.js | 27 ++++++++++++++++++--- src/skills.js | 30 +++++++++++++++++++---- tests/runs.test.js | 37 ++++++++++++++++++++++++++++ tests/skills.test.js | 58 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 8 deletions(-) diff --git a/src/runs.js b/src/runs.js index b185936d..0278a486 100644 --- a/src/runs.js +++ b/src/runs.js @@ -1,10 +1,31 @@ -import { readdir, readFile } from 'node:fs/promises'; +import { access, readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; const MAX_RUNS = 20; +async function pathExists(path) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function resolveProjectSquadsDir(targetDir) { + const marketingSquadsDir = join(targetDir, 'marketing', 'squads'); + if (await pathExists(marketingSquadsDir)) { + return marketingSquadsDir; + } + return join(targetDir, 'squads'); +} + +function isRunDirName(name) { + return /^\d{4}-\d{2}-\d{2}-\d{6}(?:-\d+)?$/.test(name); +} + export async function listRuns(squadName, targetDir = process.cwd()) { - const squadsDir = join(targetDir, 'squads'); + const squadsDir = await resolveProjectSquadsDir(targetDir); let squadNames; try { @@ -25,7 +46,7 @@ export async function listRuns(squadName, targetDir = process.cwd()) { let runDirs; try { const entries = await readdir(outputDir, { withFileTypes: true }); - runDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + runDirs = entries.filter((e) => e.isDirectory() && isRunDirName(e.name)).map((e) => e.name); } catch { continue; } diff --git a/src/skills.js b/src/skills.js index a7a1643d..8935c341 100644 --- a/src/skills.js +++ b/src/skills.js @@ -1,4 +1,4 @@ -import { cp, readdir, readFile, rm, stat } from 'node:fs/promises'; +import { access, cp, readdir, readFile, rm, stat } from 'node:fs/promises'; import { dirname, join, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -7,9 +7,26 @@ const BUNDLED_SKILLS_DIR = join(__dirname, '..', 'skills'); const metaCache = new Map(); +async function pathExists(path) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function resolveProjectSkillsDir(targetDir) { + const marketingSkillsDir = join(targetDir, 'marketing', 'skills'); + if (await pathExists(marketingSkillsDir)) { + return marketingSkillsDir; + } + return join(targetDir, 'skills'); +} + export async function listInstalled(targetDir) { try { - const skillsDir = join(targetDir, 'skills'); + const skillsDir = await resolveProjectSkillsDir(targetDir); const entries = await readdir(skillsDir, { withFileTypes: true }); return entries .filter((e) => e.isDirectory() && e.name !== 'opensquad-skill-creator') @@ -103,7 +120,8 @@ export async function installSkill(id, targetDir) { if (err.code === 'ENOENT') throw new Error(`Skill '${id}' not found in registry`, { cause: err }); throw err; } - const destDir = join(targetDir, 'skills', id); + const skillsDir = await resolveProjectSkillsDir(targetDir); + const destDir = join(skillsDir, id); const resolvedSrc = resolve(srcDir); const resolvedDest = resolve(destDir); if (resolvedSrc === resolvedDest || resolvedDest.startsWith(resolvedSrc + sep)) { @@ -115,7 +133,8 @@ export async function installSkill(id, targetDir) { export async function removeSkill(id, targetDir) { validateSkillId(id); - const skillDir = join(targetDir, 'skills', id); + const skillsDir = await resolveProjectSkillsDir(targetDir); + const skillDir = join(skillsDir, id); await rm(skillDir, { recursive: true, force: true }); metaCache.delete(id); } @@ -126,7 +145,8 @@ export function clearMetaCache() { export async function getSkillVersion(id, targetDir) { try { - const skillPath = join(targetDir, 'skills', id, 'SKILL.md'); + const skillsDir = await resolveProjectSkillsDir(targetDir); + const skillPath = join(skillsDir, id, 'SKILL.md'); const content = await readFile(skillPath, 'utf-8'); const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); if (!fmMatch) return null; diff --git a/tests/runs.test.js b/tests/runs.test.js index 3faa0a0e..9e2232a1 100644 --- a/tests/runs.test.js +++ b/tests/runs.test.js @@ -164,3 +164,40 @@ test('listRuns ignores non-directory entries in output', async () => { await rm(dir, { recursive: true, force: true }); } }); + +test('listRuns reads squads from marketing/squads when present', async () => { + const dir = await mkdtemp(join(tmpdir(), 'osq-runs-')); + try { + const runDir = join(dir, 'marketing', 'squads', 'my-squad', 'output', '2026-03-17-120000'); + await mkdir(runDir, { recursive: true }); + await writeFile(join(runDir, 'state.json'), JSON.stringify({ + squad: 'my-squad', + status: 'completed', + step: { current: 1, total: 1 }, + startedAt: '2026-03-17T12:00:00Z', + completedAt: '2026-03-17T12:01:00Z', + }), 'utf-8'); + + const runs = await listRuns(null, dir); + assert.equal(runs.length, 1); + assert.equal(runs[0].runId, '2026-03-17-120000'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('listRuns ignores non-run output directories like images and slides', async () => { + const dir = await mkdtemp(join(tmpdir(), 'osq-runs-')); + try { + const outputDir = join(dir, 'marketing', 'squads', 'my-squad', 'output'); + await mkdir(join(outputDir, 'images'), { recursive: true }); + await mkdir(join(outputDir, 'slides'), { recursive: true }); + await mkdir(join(outputDir, '2026-03-17-120000-2'), { recursive: true }); + + const runs = await listRuns(null, dir); + assert.equal(runs.length, 1); + assert.equal(runs[0].runId, '2026-03-17-120000-2'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/skills.test.js b/tests/skills.test.js index a6dce7db..9a0fa430 100644 --- a/tests/skills.test.js +++ b/tests/skills.test.js @@ -59,6 +59,19 @@ test('listInstalled returns installed skill ids from skills/', async () => { } }); +test('listInstalled prefers marketing/skills when present', async () => { + const dir = await mkdtemp(join(tmpdir(), 'opensquad-test-')); + try { + await mkdir(join(dir, 'skills', 'legacy-skill'), { recursive: true }); + await mkdir(join(dir, 'marketing', 'skills', 'department-skill'), { recursive: true }); + + const result = await listInstalled(dir); + assert.deepEqual(result, ['department-skill']); + } finally { + await rm(dir, { recursive: true }); + } +}); + // --- listAvailable --- test('listAvailable returns bundled skill ids', async () => { @@ -93,6 +106,19 @@ test('installSkill creates skills/ directory if missing', async () => { } }); +test('installSkill writes to marketing/skills when that layout exists', async () => { + const dir = await mkdtemp(join(tmpdir(), 'opensquad-test-')); + try { + await mkdir(join(dir, 'marketing', 'skills'), { recursive: true }); + await installSkill('apify', dir); + + const content = await readFile(join(dir, 'marketing', 'skills', 'apify', 'SKILL.md'), 'utf-8'); + assert.ok(content.length > 0); + } finally { + await rm(dir, { recursive: true }); + } +}); + test('installSkill throws when skill not found in bundled skills', async () => { const dir = await mkdtemp(join(tmpdir(), 'opensquad-test-')); try { @@ -175,6 +201,24 @@ test('removeSkill throws on invalid skill id', async () => { } }); +test('removeSkill deletes the skill directory from marketing/skills', async () => { + const dir = await mkdtemp(join(tmpdir(), 'opensquad-test-')); + try { + const skillDir = join(dir, 'marketing', 'skills', 'seo-optimizer'); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, 'SKILL.md'), SAMPLE_SKILL_MD); + + await removeSkill('seo-optimizer', dir); + + await assert.rejects( + () => readFile(join(skillDir, 'SKILL.md'), 'utf-8'), + { code: 'ENOENT' } + ); + } finally { + await rm(dir, { recursive: true }); + } +}); + // --- getSkillVersion --- test('getSkillVersion returns version from SKILL.md frontmatter', async () => { @@ -226,6 +270,20 @@ test('getSkillVersion returns null when SKILL.md has no frontmatter', async () = } }); +test('getSkillVersion reads from marketing/skills when present', async () => { + const dir = await mkdtemp(join(tmpdir(), 'opensquad-test-')); + try { + const skillDir = join(dir, 'marketing', 'skills', 'seo-optimizer'); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, 'SKILL.md'), SAMPLE_SKILL_MD); + + const version = await getSkillVersion('seo-optimizer', dir); + assert.equal(version, '1.2.0'); + } finally { + await rm(dir, { recursive: true }); + } +}); + // --- getSkillMeta --- test('getSkillMeta returns name, description, type, and env for a bundled skill', async () => { From 57e59dbb17cd9d124d6f33b50cacb4fadfbdc7c6 Mon Sep 17 00:00:00 2001 From: Ricardo Schroeder Canova <220612828+canovars@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:51:29 -0300 Subject: [PATCH 2/2] fix: preserve legacy layout during marketing migration --- src/init.js | 7 +-- src/readme/README.md | 4 ++ src/runs.js | 108 ++++++++++++++++++++++++++++--------------- src/skills.js | 73 +++++++++++++++++++++-------- src/update.js | 5 +- tests/runs.test.js | 53 +++++++++++++++++++++ tests/skills.test.js | 80 +++++++++++++++++++++++++++++++- 7 files changed, 266 insertions(+), 64 deletions(-) diff --git a/src/init.js b/src/init.js index 7ce6d534..a82e54b7 100644 --- a/src/init.js +++ b/src/init.js @@ -1,5 +1,5 @@ import { cp, mkdir, readdir, readFile, writeFile, stat } from 'node:fs/promises'; -import { join, dirname } from 'node:path'; +import { join, dirname, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execSync } from 'node:child_process'; import { createPrompt } from './prompt.js'; @@ -154,8 +154,9 @@ export async function loadSavedLocale(targetDir) { async function installAllSkills(targetDir) { const available = await listAvailable(); for (const id of available) { - await installSkill(id, targetDir); - console.log(` ${t('createdFile', { path: `skills/${id}/SKILL.md` })}`); + const skillDir = await installSkill(id, targetDir); + const skillPath = join(relative(targetDir, skillDir), 'SKILL.md').replaceAll('\\', '/'); + console.log(` ${t('createdFile', { path: skillPath })}`); } } diff --git a/src/readme/README.md b/src/readme/README.md index 6532fd7a..62919681 100644 --- a/src/readme/README.md +++ b/src/readme/README.md @@ -53,6 +53,8 @@ O Escritório Virtual é uma interface visual 2D que mostra seus agentes trabalh ```bash npx serve squads//dashboard +# ou, se seu projeto usa layout por departamento: +npx serve marketing/squads//dashboard ``` **Passo 3 —** Abra `http://localhost:3000` no seu navegador. @@ -114,6 +116,8 @@ The Virtual Office is a 2D visual interface that shows your agents working in re ```bash npx serve squads//dashboard +# or, if your project uses a department-based layout: +npx serve marketing/squads//dashboard ``` **Step 3 —** Open `http://localhost:3000` in your browser. diff --git a/src/runs.js b/src/runs.js index 0278a486..789cdc1c 100644 --- a/src/runs.js +++ b/src/runs.js @@ -2,6 +2,10 @@ import { access, readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; const MAX_RUNS = 20; +const PROJECT_SQUADS_DIR_PARTS = [ + ['marketing', 'squads'], + ['squads'], +]; async function pathExists(path) { try { @@ -12,68 +16,96 @@ async function pathExists(path) { } } -async function resolveProjectSquadsDir(targetDir) { - const marketingSquadsDir = join(targetDir, 'marketing', 'squads'); - if (await pathExists(marketingSquadsDir)) { - return marketingSquadsDir; +function getProjectSquadsDirCandidates(targetDir) { + return PROJECT_SQUADS_DIR_PARTS.map((parts) => join(targetDir, ...parts)); +} + +async function getExistingProjectSquadsDirs(targetDir) { + const dirs = []; + for (const dir of getProjectSquadsDirCandidates(targetDir)) { + if (await pathExists(dir)) { + dirs.push(dir); + } } - return join(targetDir, 'squads'); + return dirs; } function isRunDirName(name) { return /^\d{4}-\d{2}-\d{2}-\d{6}(?:-\d+)?$/.test(name); } -export async function listRuns(squadName, targetDir = process.cwd()) { - const squadsDir = await resolveProjectSquadsDir(targetDir); - let squadNames; +async function collectProjectSquadDirs(targetDir, squadName) { + const squadDirsByName = new Map(); - try { + for (const squadsDir of await getExistingProjectSquadsDirs(targetDir)) { if (squadName) { - squadNames = [squadName]; - } else { - const entries = await readdir(squadsDir, { withFileTypes: true }); - squadNames = entries.filter((e) => e.isDirectory()).map((e) => e.name); + const squadDir = join(squadsDir, squadName); + if (!await pathExists(squadDir)) continue; + squadDirsByName.set(squadName, [...(squadDirsByName.get(squadName) || []), squadDir]); + continue; } - } catch { - return []; - } - const runs = []; - - for (const name of squadNames) { - const outputDir = join(squadsDir, name, 'output'); - let runDirs; + let entries; try { - const entries = await readdir(outputDir, { withFileTypes: true }); - runDirs = entries.filter((e) => e.isDirectory() && isRunDirName(e.name)).map((e) => e.name); + entries = await readdir(squadsDir, { withFileTypes: true }); } catch { continue; } - for (const runId of runDirs) { - const run = { squad: name, runId, status: 'unknown', steps: null, duration: null }; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + squadDirsByName.set(entry.name, [ + ...(squadDirsByName.get(entry.name) || []), + join(squadsDir, entry.name), + ]); + } + } + + return squadDirsByName; +} + +export async function listRuns(squadName, targetDir = process.cwd()) { + const squadDirsByName = await collectProjectSquadDirs(targetDir, squadName); + const runs = new Map(); + for (const [name, squadDirs] of squadDirsByName) { + for (const squadDir of squadDirs) { + const outputDir = join(squadDir, 'output'); + let runDirs; try { - const raw = await readFile(join(outputDir, runId, 'state.json'), 'utf-8'); - const state = JSON.parse(raw); - run.status = state.status || 'unknown'; - if (state.step) run.steps = `${state.step.current}/${state.step.total}`; - if (state.startedAt && (state.completedAt || state.failedAt)) { - const start = new Date(state.startedAt).getTime(); - const end = new Date(state.completedAt || state.failedAt).getTime(); - run.duration = formatDuration(end - start); - } + const entries = await readdir(outputDir, { withFileTypes: true }); + runDirs = entries.filter((e) => e.isDirectory() && isRunDirName(e.name)).map((e) => e.name); } catch { - // No state.json or malformed — keep defaults + continue; } - runs.push(run); + for (const runId of runDirs) { + const runKey = `${name}:${runId}`; + if (runs.has(runKey)) continue; + + const run = { squad: name, runId, status: 'unknown', steps: null, duration: null }; + + try { + const raw = await readFile(join(outputDir, runId, 'state.json'), 'utf-8'); + const state = JSON.parse(raw); + run.status = state.status || 'unknown'; + if (state.step) run.steps = `${state.step.current}/${state.step.total}`; + if (state.startedAt && (state.completedAt || state.failedAt)) { + const start = new Date(state.startedAt).getTime(); + const end = new Date(state.completedAt || state.failedAt).getTime(); + run.duration = formatDuration(end - start); + } + } catch { + // No state.json or malformed — keep defaults + } + + runs.set(runKey, run); + } } } - runs.sort((a, b) => b.runId.localeCompare(a.runId)); - return runs.slice(0, MAX_RUNS); + const sortedRuns = [...runs.values()].sort((a, b) => b.runId.localeCompare(a.runId)); + return sortedRuns.slice(0, MAX_RUNS); } export function formatDuration(ms) { diff --git a/src/skills.js b/src/skills.js index 8935c341..320222f4 100644 --- a/src/skills.js +++ b/src/skills.js @@ -4,6 +4,10 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const BUNDLED_SKILLS_DIR = join(__dirname, '..', 'skills'); +const PROJECT_SKILLS_DIR_PARTS = [ + ['marketing', 'skills'], + ['skills'], +]; const metaCache = new Map(); @@ -16,25 +20,52 @@ async function pathExists(path) { } } -async function resolveProjectSkillsDir(targetDir) { - const marketingSkillsDir = join(targetDir, 'marketing', 'skills'); - if (await pathExists(marketingSkillsDir)) { +function getProjectSkillsDirCandidates(targetDir) { + return PROJECT_SKILLS_DIR_PARTS.map((parts) => join(targetDir, ...parts)); +} + +async function getExistingProjectSkillsDirs(targetDir) { + const dirs = []; + for (const dir of getProjectSkillsDirCandidates(targetDir)) { + if (await pathExists(dir)) { + dirs.push(dir); + } + } + return dirs; +} + +async function resolvePreferredProjectSkillsDir(targetDir) { + const marketingDir = join(targetDir, 'marketing'); + const marketingSkillsDir = join(marketingDir, 'skills'); + if (await pathExists(marketingDir) || await pathExists(marketingSkillsDir)) { return marketingSkillsDir; } return join(targetDir, 'skills'); } +async function resolveInstalledSkillDir(id, targetDir) { + for (const skillsDir of await getExistingProjectSkillsDirs(targetDir)) { + const skillDir = join(skillsDir, id); + if (await pathExists(skillDir)) { + return skillDir; + } + } + return null; +} + export async function listInstalled(targetDir) { - try { - const skillsDir = await resolveProjectSkillsDir(targetDir); + const installed = new Set(); + + for (const skillsDir of await getExistingProjectSkillsDirs(targetDir)) { const entries = await readdir(skillsDir, { withFileTypes: true }); - return entries - .filter((e) => e.isDirectory() && e.name !== 'opensquad-skill-creator') - .map((e) => e.name); - } catch (err) { - if (err.code === 'ENOENT') return []; - throw err; + for (const entry of entries) { + if (entry.isDirectory() && entry.name !== 'opensquad-skill-creator') { + installed.add(entry.name); + } + } } + + return [...installed]; } export async function listAvailable() { @@ -120,22 +151,25 @@ export async function installSkill(id, targetDir) { if (err.code === 'ENOENT') throw new Error(`Skill '${id}' not found in registry`, { cause: err }); throw err; } - const skillsDir = await resolveProjectSkillsDir(targetDir); - const destDir = join(skillsDir, id); + const existingSkillDir = await resolveInstalledSkillDir(id, targetDir); + const preferredSkillsDir = await resolvePreferredProjectSkillsDir(targetDir); + const destDir = existingSkillDir || join(preferredSkillsDir, id); const resolvedSrc = resolve(srcDir); const resolvedDest = resolve(destDir); if (resolvedSrc === resolvedDest || resolvedDest.startsWith(resolvedSrc + sep)) { - return; + return destDir; } await cp(srcDir, destDir, { recursive: true }); metaCache.delete(id); + return destDir; } export async function removeSkill(id, targetDir) { validateSkillId(id); - const skillsDir = await resolveProjectSkillsDir(targetDir); - const skillDir = join(skillsDir, id); - await rm(skillDir, { recursive: true, force: true }); + for (const skillsDir of getProjectSkillsDirCandidates(targetDir)) { + const skillDir = join(skillsDir, id); + await rm(skillDir, { recursive: true, force: true }); + } metaCache.delete(id); } @@ -145,8 +179,9 @@ export function clearMetaCache() { export async function getSkillVersion(id, targetDir) { try { - const skillsDir = await resolveProjectSkillsDir(targetDir); - const skillPath = join(skillsDir, id, 'SKILL.md'); + const skillDir = await resolveInstalledSkillDir(id, targetDir); + if (!skillDir) return null; + const skillPath = join(skillDir, 'SKILL.md'); const content = await readFile(skillPath, 'utf-8'); const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); if (!fmMatch) return null; diff --git a/src/update.js b/src/update.js index 789d004c..a0cf5e12 100644 --- a/src/update.js +++ b/src/update.js @@ -152,8 +152,9 @@ export async function update(targetDir) { const meta = await getSkillMeta(id); if (!meta) continue; if (meta.type === 'mcp' || meta.type === 'hybrid') continue; - await installSkill(id, targetDir); - console.log(` ${t('createdFile', { path: `skills/${id}/SKILL.md` })}`); + const skillDir = await installSkill(id, targetDir); + const skillPath = join(relative(targetDir, skillDir), 'SKILL.md').replaceAll('\\', '/'); + console.log(` ${t('createdFile', { path: skillPath })}`); count++; } diff --git a/tests/runs.test.js b/tests/runs.test.js index 9e2232a1..469e1b9a 100644 --- a/tests/runs.test.js +++ b/tests/runs.test.js @@ -201,3 +201,56 @@ test('listRuns ignores non-run output directories like images and slides', async await rm(dir, { recursive: true, force: true }); } }); + +test('listRuns preserves legacy squad history when marketing/squads also exists', async () => { + const dir = await mkdtemp(join(tmpdir(), 'osq-runs-')); + try { + await mkdir(join(dir, 'marketing', 'squads'), { recursive: true }); + const legacyRunDir = join(dir, 'squads', 'legacy-squad', 'output', '2026-03-17-120000'); + await mkdir(legacyRunDir, { recursive: true }); + await writeFile(join(legacyRunDir, 'state.json'), JSON.stringify({ + squad: 'legacy-squad', + status: 'completed', + step: { current: 1, total: 1 }, + startedAt: '2026-03-17T12:00:00Z', + completedAt: '2026-03-17T12:01:00Z', + }), 'utf-8'); + + const runs = await listRuns(null, dir); + assert.equal(runs.length, 1); + assert.equal(runs[0].squad, 'legacy-squad'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('listRuns de-duplicates identical run ids across layouts during migration', async () => { + const dir = await mkdtemp(join(tmpdir(), 'osq-runs-')); + try { + const runId = '2026-03-17-120000'; + const marketingRunDir = join(dir, 'marketing', 'squads', 'my-squad', 'output', runId); + const legacyRunDir = join(dir, 'squads', 'my-squad', 'output', runId); + await mkdir(marketingRunDir, { recursive: true }); + await mkdir(legacyRunDir, { recursive: true }); + await writeFile(join(marketingRunDir, 'state.json'), JSON.stringify({ + squad: 'my-squad', + status: 'completed', + step: { current: 1, total: 1 }, + startedAt: '2026-03-17T12:00:00Z', + completedAt: '2026-03-17T12:01:00Z', + }), 'utf-8'); + await writeFile(join(legacyRunDir, 'state.json'), JSON.stringify({ + squad: 'my-squad', + status: 'failed', + step: { current: 1, total: 1 }, + startedAt: '2026-03-17T12:00:00Z', + failedAt: '2026-03-17T12:00:30Z', + }), 'utf-8'); + + const runs = await listRuns(null, dir); + assert.equal(runs.length, 1); + assert.equal(runs[0].status, 'completed'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/skills.test.js b/tests/skills.test.js index 9a0fa430..7090c223 100644 --- a/tests/skills.test.js +++ b/tests/skills.test.js @@ -59,14 +59,16 @@ test('listInstalled returns installed skill ids from skills/', async () => { } }); -test('listInstalled prefers marketing/skills when present', async () => { +test('listInstalled includes installed skills from both legacy and marketing layouts', async () => { const dir = await mkdtemp(join(tmpdir(), 'opensquad-test-')); try { await mkdir(join(dir, 'skills', 'legacy-skill'), { recursive: true }); await mkdir(join(dir, 'marketing', 'skills', 'department-skill'), { recursive: true }); const result = await listInstalled(dir); - assert.deepEqual(result, ['department-skill']); + assert.ok(result.includes('legacy-skill')); + assert.ok(result.includes('department-skill')); + assert.equal(result.length, 2); } finally { await rm(dir, { recursive: true }); } @@ -119,6 +121,40 @@ test('installSkill writes to marketing/skills when that layout exists', async () } }); +test('installSkill writes to marketing/skills when marketing/ exists but skills/ does not', async () => { + const dir = await mkdtemp(join(tmpdir(), 'opensquad-test-')); + try { + await mkdir(join(dir, 'marketing'), { recursive: true }); + await installSkill('apify', dir); + + const content = await readFile(join(dir, 'marketing', 'skills', 'apify', 'SKILL.md'), 'utf-8'); + assert.ok(content.length > 0); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test('installSkill updates the existing legacy skill in place during migration', async () => { + const dir = await mkdtemp(join(tmpdir(), 'opensquad-test-')); + try { + await mkdir(join(dir, 'marketing', 'skills'), { recursive: true }); + const legacySkillDir = join(dir, 'skills', 'apify'); + await mkdir(legacySkillDir, { recursive: true }); + await writeFile(join(legacySkillDir, 'SKILL.md'), 'legacy copy', 'utf-8'); + + await installSkill('apify', dir); + + const updatedLegacyContent = await readFile(join(dir, 'skills', 'apify', 'SKILL.md'), 'utf-8'); + assert.notEqual(updatedLegacyContent, 'legacy copy'); + await assert.rejects( + () => readFile(join(dir, 'marketing', 'skills', 'apify', 'SKILL.md'), 'utf-8'), + { code: 'ENOENT' } + ); + } finally { + await rm(dir, { recursive: true }); + } +}); + test('installSkill throws when skill not found in bundled skills', async () => { const dir = await mkdtemp(join(tmpdir(), 'opensquad-test-')); try { @@ -219,6 +255,31 @@ test('removeSkill deletes the skill directory from marketing/skills', async () = } }); +test('removeSkill deletes duplicated skill directories from both layouts', async () => { + const dir = await mkdtemp(join(tmpdir(), 'opensquad-test-')); + try { + const legacySkillDir = join(dir, 'skills', 'seo-optimizer'); + const marketingSkillDir = join(dir, 'marketing', 'skills', 'seo-optimizer'); + await mkdir(legacySkillDir, { recursive: true }); + await mkdir(marketingSkillDir, { recursive: true }); + await writeFile(join(legacySkillDir, 'SKILL.md'), SAMPLE_SKILL_MD); + await writeFile(join(marketingSkillDir, 'SKILL.md'), SAMPLE_SKILL_MD); + + await removeSkill('seo-optimizer', dir); + + await assert.rejects( + () => readFile(join(legacySkillDir, 'SKILL.md'), 'utf-8'), + { code: 'ENOENT' } + ); + await assert.rejects( + () => readFile(join(marketingSkillDir, 'SKILL.md'), 'utf-8'), + { code: 'ENOENT' } + ); + } finally { + await rm(dir, { recursive: true }); + } +}); + // --- getSkillVersion --- test('getSkillVersion returns version from SKILL.md frontmatter', async () => { @@ -284,6 +345,21 @@ test('getSkillVersion reads from marketing/skills when present', async () => { } }); +test('getSkillVersion reads from legacy skills/ during a mixed-layout migration', async () => { + const dir = await mkdtemp(join(tmpdir(), 'opensquad-test-')); + try { + await mkdir(join(dir, 'marketing', 'skills'), { recursive: true }); + const skillDir = join(dir, 'skills', 'seo-optimizer'); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, 'SKILL.md'), SAMPLE_SKILL_MD); + + const version = await getSkillVersion('seo-optimizer', dir); + assert.equal(version, '1.2.0'); + } finally { + await rm(dir, { recursive: true }); + } +}); + // --- getSkillMeta --- test('getSkillMeta returns name, description, type, and env for a bundled skill', async () => {