diff --git a/README.md b/README.md index 921f696bf..9e7b0a023 100644 --- a/README.md +++ b/README.md @@ -564,6 +564,8 @@ atris upgrade # Install latest from npm atris update # Sync local workspace files to new version ``` +Skill synchronization adds missing packaged files but preserves existing differing project and global skill files, including empty files and symlinks. Preserved files are reported; to adopt a newer packaged version, compare it with your copy and update your copy intentionally. Atris does not currently track which older files were unmodified package versions. + Packaged npm installs check for new versions during normal commands and start a background update automatically. Git checkout installs stay manual so linked development copies are not overwritten unexpectedly. --- diff --git a/bin/atris.js b/bin/atris.js index d0cdb2747..a4f5f5de0 100755 --- a/bin/atris.js +++ b/bin/atris.js @@ -326,10 +326,11 @@ if (!helpRequested && !dryRunRequested && ( const { syncSkills } = require('../commands/sync'); const skillsUpdated = syncSkills({ silent: true }); if (skillsUpdated > 0) { - console.log(`${skillsUpdated} skill${skillsUpdated > 1 ? 's' : ''} updated`); + console.log(`${skillsUpdated} skill file${skillsUpdated > 1 ? 's' : ''} added`); } } catch (e) { - // Non-critical + console.error(`Skill sync refused: ${e.message}`); + process.exit(1); } } diff --git a/commands/sync.js b/commands/sync.js index b9b790faf..c0c4f8165 100644 --- a/commands/sync.js +++ b/commands/sync.js @@ -91,11 +91,51 @@ function ensureRealDirectory(dir) { fs.mkdirSync(dir, { recursive: true }); } -// A hook script the project already has is never replaced. Projects harden -// their hooks (nonce checks, queues, proof before pop); a sync must not -// downgrade them to the shipped copy. -function isProtectedHook(destPath) { - return path.basename(path.dirname(destPath)) === 'hooks' && fs.existsSync(destPath); +// Check each component below the trusted workspace or home root before +// creating directories; never follow a link into another owner's tree. +function ensureSkillDirectory(dir, root, { create = true } = {}) { + const relative = path.relative(root, dir); + if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error(`Unsafe skill path: ${dir}`); + let current = root; + for (const part of relative.split(path.sep).filter(Boolean)) { + current = path.join(current, part); + let stat; + try { stat = fs.lstatSync(current); } catch (error) { + if (error.code !== 'ENOENT') throw error; + if (!create) return; + try { fs.mkdirSync(current); } catch (mkdirError) { + if (mkdirError.code !== 'EEXIST') throw mkdirError; + } + stat = fs.lstatSync(current); + } + if (!stat.isDirectory()) throw new Error(`${current} is not a real directory; skill sync skipped`); + } +} + +// Existing skill files are user-owned unless an ownership baseline proves +// otherwise. We have no such baseline, so never replace or follow them. +function syncSkillFile(srcPath, destPath, label, { dryRun = false, silent = false } = {}) { + const source = fs.readFileSync(srcPath); + let stat; + try { stat = fs.lstatSync(destPath); } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + if (stat) { + if (!stat.isFile() || !source.equals(fs.readFileSync(destPath))) { + console.warn(`- Kept ${label} (existing skill file not replaced)`); + } + return 0; + } + if (!dryRun) { + try { fs.writeFileSync(destPath, source, { flag: 'wx' }); } catch (error) { + if (error.code !== 'EEXIST') throw error; + console.warn(`- Kept ${label} (existing skill file not replaced)`); + return 0; + } + if (srcPath.endsWith('.sh')) fs.chmodSync(destPath, 0o755); + } + if (!silent) console.log(`${dryRun ? 'Would add' : '✓ Added'} ${label}`); + return 1; } // A member retired to team/_archived/-/ must never come back. @@ -128,8 +168,8 @@ function syncPackageSkills(targetAtrisDir, opts = {}) { if (!fs.existsSync(packageSkillsDir)) return 0; if (!dryRun) { - ensureRealDirectory(userSkillsDir); - ensureRealDirectory(claudeSkillsBaseDir); + ensureSkillDirectory(userSkillsDir, path.dirname(targetAtrisDir)); + ensureSkillDirectory(claudeSkillsBaseDir, path.dirname(targetAtrisDir)); } const skillFolders = fs.readdirSync(packageSkillsDir).filter(f => { @@ -143,7 +183,14 @@ function syncPackageSkills(targetAtrisDir, opts = {}) { const symlinkPath = path.join(claudeSkillsBaseDir, skill); const syncRecursive = (src, dest, skillName, basePath = '') => { - if (!dryRun) ensureRealDirectory(dest); + try { + const existing = fs.lstatSync(dest); + if (!existing.isDirectory()) { + console.warn(`- Kept atris/skills/${skillName}${basePath ? '/' + basePath : ''} (existing skill directory not replaced)`); + return; + } + } catch (error) { if (error.code !== 'ENOENT') throw error; } + ensureSkillDirectory(dest, path.dirname(targetAtrisDir), { create: !dryRun }); for (const entry of fs.readdirSync(src)) { const srcPath = path.join(src, entry); const destPath = path.join(dest, entry); @@ -151,18 +198,9 @@ function syncPackageSkills(targetAtrisDir, opts = {}) { if (fs.statSync(srcPath).isDirectory()) { syncRecursive(srcPath, destPath, skillName, relPath); } else { - const srcContent = fs.readFileSync(srcPath, 'utf8'); - const destContent = fs.existsSync(destPath) ? fs.readFileSync(destPath, 'utf8') : ''; - if (srcContent !== destContent && isProtectedHook(destPath)) { - if (verbose) console.log(`- Kept atris/skills/${skillName}/${relPath} (differs from shipped hook, not replaced)`); - } else if (srcContent !== destContent) { - if (!dryRun) { - fs.writeFileSync(destPath, srcContent); - if (entry.endsWith('.sh')) fs.chmodSync(destPath, 0o755); - } - if (verbose) console.log(`${dryRun ? 'Would update' : '✓ Updated'} atris/skills/${skillName}/${relPath}`); - updated++; - } + updated += syncSkillFile(srcPath, destPath, `atris/skills/${skillName}/${relPath}`, { + dryRun, silent: !verbose, + }); } } }; @@ -178,12 +216,7 @@ function syncPackageSkills(targetAtrisDir, opts = {}) { fs.symlinkSync(relativePath, symlinkPath); if (verbose) console.log(`✓ Linked .claude/skills/${skill}`); } catch (e) { - ensureRealDirectory(symlinkPath); - const skillFile = path.join(destSkillDir, 'SKILL.md'); - if (fs.existsSync(skillFile)) { - fs.copyFileSync(skillFile, path.join(symlinkPath, 'SKILL.md')); - } - if (verbose) console.log(`✓ Copied .claude/skills/${skill} (symlink failed)`); + console.log(`- Kept .claude/skills/${skill} (link could not be created: ${e.code || e.message})`); } } } @@ -411,9 +444,9 @@ function syncWorkspaceTemplate(targetRoot, bizMeta, options = {}) { console.log(` Added: ${added}`); console.log(` Updated: ${updated} ${force ? '' : '(--force to enable)'}`); - console.log(` Preserved: ${preserved} (existing customizations kept)`); + console.log(` Template files preserved: ${preserved} (existing customizations kept)`); console.log(` Skipped: ${skipped} (already match template)`); - console.log(` Skills: ${skillsUpdated} ${dryRun ? 'would update' : 'updated'} from atris-cli/atris/skills/`); + console.log(` Skills: ${skillsUpdated} ${dryRun ? 'would add' : 'added'} from atris-cli/atris/skills/`); console.log(''); if (addedList.length > 0) { @@ -655,51 +688,8 @@ function syncAtris(options = {}) { updated++; } - // Update .claude/skills/atris/SKILL.md (legacy - now handled above, keeping for compatibility) - const claudeSkillsDir = path.join(process.cwd(), '.claude', 'skills', 'atris'); - const claudeSkillFile = path.join(claudeSkillsDir, 'SKILL.md'); - const skillContent = `--- -name: atris -description: Atris workspace navigation for atris repos, TODO files, tasks, MAP.md, backlog, and where-is-X questions. -allowed-tools: Read, Bash, Glob, Grep, Write, Edit ---- - -# Atris Skill - -## IMPORTANT: First Interaction - -**On your FIRST message in any Atris project, IMMEDIATELY run:** - -\`\`\`bash -atris atris.md -\`\`\` - -Show the full ASCII output to the user. This displays the Atris welcome visualization with workspace stats. Do this BEFORE responding to whatever the user said. - -## Workflow - -Detect: Project has \`atris/\` folder with MAP.md, TODO.md, PERSONA.md - -Commands: plan → do → review - -Key behaviors: -- Read PERSONA.md (MAP.md first, plain words, lead with the outcome) -- Check MAP.md for file:line refs -- Use \`atris task\` for claims, proof, ready, and accept -- Give every task a plain first layer: what changes, why it matters, and what - done looks like. Keep exact technical detail underneath and use the existing - approve/change gates; never bypass proof. -- Treat TODO.md as a rendered view; regenerate it instead of hand-editing tasks`; - - if (!dryRun && !fs.existsSync(claudeSkillsDir)) { - fs.mkdirSync(claudeSkillsDir, { recursive: true }); - } - const currentSkill = fs.existsSync(claudeSkillFile) ? fs.readFileSync(claudeSkillFile, 'utf8') : ''; - if (currentSkill !== skillContent) { - if (!dryRun) fs.writeFileSync(claudeSkillFile, skillContent); - console.log(`${dryRun ? 'Would update' : '✓ Updated'} .claude/skills/atris/SKILL.md`); - updated++; - } + // syncPackageSkills above creates the canonical Atris skill and link. Do + // not write through .claude/skills/atris: it may be a user-owned link. // Update .claude/settings.json with startup and per-prompt hooks. const claudeSettingsFile = path.join(process.cwd(), '.claude', 'settings.json'); @@ -778,31 +768,25 @@ Key behaviors: /** * Recursively sync files from src to dest. Returns count of files updated. */ -function syncRecursiveCount(src, dest, label, silent) { +function syncRecursiveCount(src, dest, label, silent, root) { let count = 0; - ensureRealDirectory(dest); + try { + const existing = fs.lstatSync(dest); + if (!existing.isDirectory()) { + console.warn(`- Kept ${label} (existing skill directory not replaced)`); + return 0; + } + } catch (error) { if (error.code !== 'ENOENT') throw error; } + ensureSkillDirectory(dest, root); const entries = fs.readdirSync(src); for (const entry of entries) { const srcPath = path.join(src, entry); const destPath = path.join(dest, entry); if (fs.statSync(srcPath).isDirectory()) { - count += syncRecursiveCount(srcPath, destPath, `${label}/${entry}`, silent); + count += syncRecursiveCount(srcPath, destPath, `${label}/${entry}`, silent, root); } else { - const srcBuf = fs.readFileSync(srcPath); - const destBuf = fs.existsSync(destPath) ? fs.readFileSync(destPath) : Buffer.alloc(0); - if (!srcBuf.equals(destBuf) && isProtectedHook(destPath)) { - if (!silent) console.log(`- Kept ${label}/${entry} (differs from shipped hook, not replaced)`); - } else if (!srcBuf.equals(destBuf)) { - fs.writeFileSync(destPath, srcBuf); - if (entry.endsWith('.sh')) { - fs.chmodSync(destPath, 0o755); - } - if (!silent) { - console.log(`✓ Updated ${label}/${entry}`); - } - count++; - } + count += syncSkillFile(srcPath, destPath, `${label}/${entry}`, { silent }); } } return count; @@ -829,6 +813,14 @@ function syncSkills({ silent = false } = {}) { fs.statSync(path.join(packageSkillsDir, f)).isDirectory() ); + // Preflight before init or update gets a chance to traverse these roots. + for (const dir of [path.join(homeDir, '.claude', 'skills'), path.join(homeDir, '.codex', 'skills')]) { + ensureSkillDirectory(dir, homeDir, { create: false }); + } + for (const dir of [path.join(process.cwd(), '.claude', 'skills'), path.join(process.cwd(), 'atris', 'skills')]) { + ensureSkillDirectory(dir, process.cwd(), { create: false }); + } + // --- 1. Global skill directories (sync if they exist) --- const globalSkillDirs = [ path.join(homeDir, '.claude', 'skills'), @@ -844,7 +836,7 @@ function syncSkills({ silent = false } = {}) { const destSkillDir = path.join(globalDir, skill); if (fs.existsSync(destSkillDir) || fs.existsSync(globalDir)) { - updated += syncRecursiveCount(srcSkillDir, destSkillDir, `~/${dirName}/skills/${skill}`, silent); + updated += syncRecursiveCount(srcSkillDir, destSkillDir, `~/${dirName}/skills/${skill}`, silent, homeDir); } } } @@ -865,15 +857,15 @@ function syncSkills({ silent = false } = {}) { const userSkillsDir = path.join(targetDir, 'skills'); const claudeSkillsBaseDir = path.join(process.cwd(), '.claude', 'skills'); - ensureRealDirectory(userSkillsDir); - ensureRealDirectory(claudeSkillsBaseDir); + ensureSkillDirectory(userSkillsDir, process.cwd()); + ensureSkillDirectory(claudeSkillsBaseDir, process.cwd()); for (const skill of skillFolders) { const srcSkillDir = path.join(packageSkillsDir, skill); const destSkillDir = path.join(userSkillsDir, skill); const symlinkPath = path.join(claudeSkillsBaseDir, skill); - updated += syncRecursiveCount(srcSkillDir, destSkillDir, `atris/skills/${skill}`, silent); + updated += syncRecursiveCount(srcSkillDir, destSkillDir, `atris/skills/${skill}`, silent, process.cwd()); // Create symlink if doesn't exist if (!fs.existsSync(symlinkPath)) { @@ -884,12 +876,7 @@ function syncSkills({ silent = false } = {}) { console.log(`✓ Linked .claude/skills/${skill}`); } } catch (e) { - // Fallback: copy instead of symlink - ensureRealDirectory(symlinkPath); - const skillFile = path.join(destSkillDir, 'SKILL.md'); - if (fs.existsSync(skillFile)) { - fs.copyFileSync(skillFile, path.join(symlinkPath, 'SKILL.md')); - } + console.log(`- Kept .claude/skills/${skill} (link could not be created: ${e.code || e.message})`); } } } diff --git a/test/skill-sync-preservation.test.js b/test/skill-sync-preservation.test.js new file mode 100644 index 000000000..09a8c71dd --- /dev/null +++ b/test/skill-sync-preservation.test.js @@ -0,0 +1,95 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const repo = path.resolve(__dirname, '..'); +const cli = path.join(repo, 'bin', 'atris.js'); +const packaged = fs.readFileSync(path.join(repo, 'atris/skills/atris/SKILL.md')); + +for (const args of [['init', '--minimal', '--yes', '--no-commit'], ['update', '--yes'], ['sync', '--yes']]) { + test(`${args[0]} preserves customized project and global skills while installing missing skills`, (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-skill-preserve-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const cwd = path.join(root, 'project'), home = path.join(root, 'home'); + fs.mkdirSync(cwd); fs.mkdirSync(home); + const owned = [path.join(cwd, 'atris/skills/atris/SKILL.md'), path.join(home, '.codex/skills/atris/SKILL.md'), path.join(home, '.claude/skills/atris/SKILL.md')]; + for (const file of owned) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, 'custom user text\n', { mode: 0o600 }); } + const nested = path.join(cwd, 'atris/skills/autopilot/hooks/stop-hook.sh'); + fs.mkdirSync(path.dirname(nested), { recursive: true }); fs.writeFileSync(nested, 'custom hook\n'); + const env = { ...process.env, HOME: home, USERPROFILE: home, ATRIS_SKIP_UPDATE_CHECK: '1', ATRIS_TASKS_DB: path.join(root, 'tasks.db'), ATRIS_CAMPAIGN_DB: path.join(root, 'campaign.db'), ATRIS_RSI_STATE: path.join(root, 'rsi') }; + delete env.NODE_TEST_CONTEXT; delete env.ATRIS_TEST_STATE_DIR; delete env.ATRIS_TEST_PROTECTED_HOME; + const result = spawnSync(process.execPath, [cli, ...args], { cwd, env, encoding: 'utf8', timeout: 30000 }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr + result.stdout, /existing skill file not replaced/); + for (const file of owned) { assert.equal(fs.readFileSync(file, 'utf8'), 'custom user text\n'); assert.equal(fs.statSync(file).mode & 0o777, 0o600); } + assert.equal(fs.readFileSync(nested, 'utf8'), 'custom hook\n'); + assert.ok(fs.existsSync(path.join(cwd, 'atris/skills/aeo/SKILL.md'))); + assert.ok(fs.existsSync(path.join(home, '.codex/skills/aeo/SKILL.md'))); + }); +} + +test('sync package skills preserves empty files, symlinks and dangling symlinks', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-skill-links-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const skills = path.join(root, 'atris/skills'); + fs.mkdirSync(path.join(skills, 'atris'), { recursive: true }); + fs.writeFileSync(path.join(skills, 'atris/SKILL.md'), ''); + fs.mkdirSync(path.join(skills, 'aeo'), { recursive: true }); + const target = path.join(root, 'owned'); fs.writeFileSync(target, 'owned\n'); + fs.symlinkSync(target, path.join(skills, 'aeo/SKILL.md')); + fs.mkdirSync(path.join(skills, 'autopilot'), { recursive: true }); + fs.symlinkSync(path.join(root, 'missing'), path.join(skills, 'autopilot/SKILL.md')); + const { syncWorkspaceTemplate } = require('../commands/sync'); + syncWorkspaceTemplate(root, { name: 'Fixture', slug: 'fixture', business_id: 'fixture', workspace_id: 'fixture' }, { templateName: 'business' }); + assert.equal(fs.readFileSync(path.join(skills, 'atris/SKILL.md'), 'utf8'), ''); + assert.equal(fs.readFileSync(target, 'utf8'), 'owned\n'); + assert.ok(fs.lstatSync(path.join(skills, 'aeo/SKILL.md')).isSymbolicLink()); + assert.ok(fs.lstatSync(path.join(skills, 'autopilot/SKILL.md')).isSymbolicLink()); + assert.equal(fs.existsSync(path.join(root, 'missing')), false); + assert.notDeepEqual(Buffer.alloc(0), packaged); +}); + +for (const scenario of ['legacy-leaf-link', 'claude-ancestor-link']) { + test(`real CLI does not write through ${scenario}`, (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-skill-parent-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const cwd = path.join(root, 'project'), home = path.join(root, 'home'), outside = path.join(root, 'outside'); + fs.mkdirSync(cwd); fs.mkdirSync(home); fs.mkdirSync(outside); + fs.mkdirSync(path.join(cwd, 'atris')); + let args; + if (scenario === 'legacy-leaf-link') { + fs.mkdirSync(path.join(cwd, '.claude/skills'), { recursive: true }); + fs.symlinkSync(outside, path.join(cwd, '.claude/skills/atris')); + args = ['update', '--yes']; + } else { + fs.symlinkSync(outside, path.join(cwd, '.claude')); + args = ['init', '--minimal', '--yes', '--no-commit']; + } + const env = { ...process.env, HOME: home, USERPROFILE: home, ATRIS_SKIP_UPDATE_CHECK: '1', ATRIS_TASKS_DB: path.join(root, 'tasks.db'), ATRIS_CAMPAIGN_DB: path.join(root, 'campaign.db') }; + delete env.NODE_TEST_CONTEXT; delete env.ATRIS_TEST_STATE_DIR; delete env.ATRIS_TEST_PROTECTED_HOME; + const result = spawnSync(process.execPath, [cli, ...args], { cwd, env, encoding: 'utf8', timeout: 30000 }); + assert.equal(result.status, scenario === 'legacy-leaf-link' ? 0 : 1, result.stderr); + assert.deepEqual(fs.readdirSync(outside), []); + if (scenario === 'claude-ancestor-link') assert.match(result.stderr, /Skill sync refused/); + }); +} + +for (const args of [['update', '--dry-run'], ['update', '--yes']]) { + test(`${args.join(' ')} skips a linked custom project skill directory`, (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-skill-subtree-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const cwd = path.join(root, 'project'), home = path.join(root, 'home'), outside = path.join(root, 'outside'); + fs.mkdirSync(path.join(cwd, 'atris/skills'), { recursive: true }); fs.mkdirSync(home); fs.mkdirSync(outside); + fs.symlinkSync(outside, path.join(cwd, 'atris/skills/aeo')); + const env = { ...process.env, HOME: home, USERPROFILE: home, ATRIS_SKIP_UPDATE_CHECK: '1', ATRIS_TASKS_DB: path.join(root, 'tasks.db'), ATRIS_CAMPAIGN_DB: path.join(root, 'campaign.db') }; + delete env.NODE_TEST_CONTEXT; delete env.ATRIS_TEST_STATE_DIR; delete env.ATRIS_TEST_PROTECTED_HOME; + const result = spawnSync(process.execPath, [cli, ...args], { cwd, env, encoding: 'utf8', timeout: 30000 }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /existing skill directory not replaced/); + assert.deepEqual(fs.readdirSync(outside), []); + assert.ok(fs.lstatSync(path.join(cwd, 'atris/skills/aeo')).isSymbolicLink()); + }); +} diff --git a/test/sync-all.test.js b/test/sync-all.test.js index 2ddc40607..f167c5a46 100644 --- a/test/sync-all.test.js +++ b/test/sync-all.test.js @@ -227,21 +227,21 @@ test('node_modules and .git subtrees are not scanned', () => { } }); -test('workspace template sync repairs broken .claude skills link', () => { +test('workspace template sync preserves broken .claude skills link', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-sync-broken-claude-')); try { fs.mkdirSync(path.join(root, '.claude'), { recursive: true }); fs.symlinkSync(path.join(root, 'missing-skills-target'), path.join(root, '.claude', 'skills')); - assert.doesNotThrow(() => syncWorkspaceTemplate(root, { + assert.throws(() => syncWorkspaceTemplate(root, { name: 'Broken Skills Co', slug: 'broken-skills', business_id: 'biz-broken-skills', workspace_id: 'ws-broken-skills', }, { templateName: 'business' })); - assert.ok(fs.lstatSync(path.join(root, '.claude', 'skills')).isDirectory()); - assert.ok(fs.existsSync(path.join(root, '.claude', 'skills', 'aeo', 'SKILL.md'))); + assert.ok(fs.lstatSync(path.join(root, '.claude', 'skills')).isSymbolicLink()); + assert.equal(fs.existsSync(path.join(root, 'missing-skills-target')), false); } finally { cleanup(root); } diff --git a/test/sync-skills-state-home.test.js b/test/sync-skills-state-home.test.js index d2f64e25f..553d6f40c 100644 --- a/test/sync-skills-state-home.test.js +++ b/test/sync-skills-state-home.test.js @@ -87,7 +87,7 @@ function run(f, args) { function assertGlobalSkills(home) { for (const dir of globalDirs) { const skills = path.join(home, dir, 'skills'); - assert.deepEqual(fs.readFileSync(path.join(skills, skill)), packagedSkill); + assert.equal(fs.readFileSync(path.join(skills, skill), 'utf8'), 'stale owned skill\n'); assert.equal(fs.readFileSync(path.join(skills, hook), 'utf8'), customHook); } } diff --git a/test/update-dry-run.test.js b/test/update-dry-run.test.js index 7fa9f90a4..22195fa2c 100644 --- a/test/update-dry-run.test.js +++ b/test/update-dry-run.test.js @@ -100,7 +100,7 @@ test('business update dry-run reports stale skill changes without writing', () = }, }); assert.equal(result.status, 0, result.stderr || result.stdout); - assert.match(result.stdout, /Skills:\s+[1-9]\d* would update from atris-cli\/atris\/skills\//); + assert.match(result.stdout, /Skills:\s+[1-9]\d* would add from atris-cli\/atris\/skills\//); assert.deepEqual(snapshotTree(fixture.workspace), beforeWorkspace, 'business update changed workspace files'); assert.deepEqual(snapshotTree(fixture.home), beforeHome, 'business update changed global files'); } finally {