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 b185936d..789cdc1c 100644 --- a/src/runs.js +++ b/src/runs.js @@ -1,58 +1,111 @@ -import { readdir, readFile } from 'node:fs/promises'; +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'], +]; -export async function listRuns(squadName, targetDir = process.cwd()) { - const squadsDir = join(targetDir, 'squads'); - let squadNames; - +async function pathExists(path) { try { - if (squadName) { - squadNames = [squadName]; - } else { - const entries = await readdir(squadsDir, { withFileTypes: true }); - squadNames = entries.filter((e) => e.isDirectory()).map((e) => e.name); - } + await access(path); + return true; } catch { - return []; + return false; } +} - const runs = []; +function getProjectSquadsDirCandidates(targetDir) { + return PROJECT_SQUADS_DIR_PARTS.map((parts) => join(targetDir, ...parts)); +} - for (const name of squadNames) { - const outputDir = join(squadsDir, name, 'output'); - let runDirs; +async function getExistingProjectSquadsDirs(targetDir) { + const dirs = []; + for (const dir of getProjectSquadsDirCandidates(targetDir)) { + if (await pathExists(dir)) { + dirs.push(dir); + } + } + return dirs; +} + +function isRunDirName(name) { + return /^\d{4}-\d{2}-\d{2}-\d{6}(?:-\d+)?$/.test(name); +} + +async function collectProjectSquadDirs(targetDir, squadName) { + const squadDirsByName = new Map(); + + for (const squadsDir of await getExistingProjectSquadsDirs(targetDir)) { + if (squadName) { + const squadDir = join(squadsDir, squadName); + if (!await pathExists(squadDir)) continue; + squadDirsByName.set(squadName, [...(squadDirsByName.get(squadName) || []), squadDir]); + continue; + } + + let entries; try { - const entries = await readdir(outputDir, { withFileTypes: true }); - runDirs = entries.filter((e) => e.isDirectory()).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 a7a1643d..320222f4 100644 --- a/src/skills.js +++ b/src/skills.js @@ -1,23 +1,71 @@ -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'; 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(); -export async function listInstalled(targetDir) { +async function pathExists(path) { try { - const skillsDir = join(targetDir, 'skills'); + await access(path); + return true; + } catch { + return false; + } +} + +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) { + 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() { @@ -103,20 +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 destDir = join(targetDir, 'skills', 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 skillDir = join(targetDir, 'skills', 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); } @@ -126,7 +179,9 @@ export function clearMetaCache() { export async function getSkillVersion(id, targetDir) { try { - const skillPath = join(targetDir, 'skills', 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 3faa0a0e..469e1b9a 100644 --- a/tests/runs.test.js +++ b/tests/runs.test.js @@ -164,3 +164,93 @@ 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 }); + } +}); + +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 a6dce7db..7090c223 100644 --- a/tests/skills.test.js +++ b/tests/skills.test.js @@ -59,6 +59,21 @@ test('listInstalled returns installed skill ids from skills/', 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.ok(result.includes('legacy-skill')); + assert.ok(result.includes('department-skill')); + assert.equal(result.length, 2); + } finally { + await rm(dir, { recursive: true }); + } +}); + // --- listAvailable --- test('listAvailable returns bundled skill ids', async () => { @@ -93,6 +108,53 @@ 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 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 { @@ -175,6 +237,49 @@ 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 }); + } +}); + +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 () => { @@ -226,6 +331,35 @@ 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 }); + } +}); + +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 () => {