feat: TypeScript installation wizard with @clack/prompts TUI (#1)

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-02-21 18:25:51 +00:00
committed by jason.woltje
parent e3ec3e32e5
commit 6a84f7e210
56 changed files with 20647 additions and 31 deletions

View File

@@ -0,0 +1,109 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
mkdtempSync,
mkdirSync,
writeFileSync,
readFileSync,
existsSync,
rmSync,
cpSync,
} from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
import { createConfigService } from '../../src/config/config-service.js';
import { runWizard } from '../../src/wizard.js';
describe('Full Wizard (headless)', () => {
let tmpDir: string;
const repoRoot = join(import.meta.dirname, '..', '..');
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-'));
// Copy templates to tmp dir
const templatesDir = join(repoRoot, 'templates');
if (existsSync(templatesDir)) {
cpSync(templatesDir, join(tmpDir, 'templates'), { recursive: true });
}
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('quick start produces valid SOUL.md', async () => {
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'They/Them',
'Your timezone': 'UTC',
});
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
});
const soulPath = join(tmpDir, 'SOUL.md');
expect(existsSync(soulPath)).toBe(true);
const soul = readFileSync(soulPath, 'utf-8');
expect(soul).toContain('You are **TestBot**');
expect(soul).toContain('Be direct, concise, and concrete');
expect(soul).toContain('execution partner and visibility engine');
});
it('quick start produces valid USER.md', async () => {
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'He/Him',
'Your timezone': 'America/Chicago',
});
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
});
const userPath = join(tmpDir, 'USER.md');
expect(existsSync(userPath)).toBe(true);
const user = readFileSync(userPath, 'utf-8');
expect(user).toContain('**Name:** Tester');
expect(user).toContain('**Pronouns:** He/Him');
expect(user).toContain('**Timezone:** America/Chicago');
});
it('applies CLI overrides', async () => {
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'Your name': 'FromPrompt',
});
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
cliOverrides: {
soul: {
agentName: 'FromCLI',
communicationStyle: 'formal',
},
},
});
const soul = readFileSync(join(tmpDir, 'SOUL.md'), 'utf-8');
expect(soul).toContain('You are **FromCLI**');
expect(soul).toContain('Use professional, structured language');
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
import { detectInstallStage } from '../../src/stages/detect-install.js';
import type { WizardState } from '../../src/types.js';
import type { ConfigService } from '../../src/config/config-service.js';
function createState(mosaicHome: string): WizardState {
return {
mosaicHome,
sourceDir: mosaicHome,
mode: 'quick',
installAction: 'fresh',
soul: {},
user: {},
tools: {},
runtimes: { detected: [], mcpConfigured: false },
selectedSkills: [],
};
}
const mockConfig: ConfigService = {
readSoul: async () => ({ agentName: 'TestAgent' }),
readUser: async () => ({ userName: 'TestUser' }),
readTools: async () => ({}),
writeSoul: async () => {},
writeUser: async () => {},
writeTools: async () => {},
syncFramework: async () => {},
};
describe('detectInstallStage', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-test-'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('sets fresh for empty directory', async () => {
const p = new HeadlessPrompter({});
const state = createState(join(tmpDir, 'nonexistent'));
await detectInstallStage(p, state, mockConfig);
expect(state.installAction).toBe('fresh');
});
it('detects existing install and offers choices', async () => {
// Create a mock existing install
mkdirSync(join(tmpDir, 'bin'), { recursive: true });
writeFileSync(join(tmpDir, 'AGENTS.md'), '# Test');
writeFileSync(
join(tmpDir, 'SOUL.md'),
'You are **Jarvis** in this session.',
);
const p = new HeadlessPrompter({
'What would you like to do?': 'keep',
});
const state = createState(tmpDir);
await detectInstallStage(p, state, mockConfig);
expect(state.installAction).toBe('keep');
expect(state.soul.agentName).toBe('TestAgent');
});
});

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
import { soulSetupStage } from '../../src/stages/soul-setup.js';
import type { WizardState } from '../../src/types.js';
function createState(overrides: Partial<WizardState> = {}): WizardState {
return {
mosaicHome: '/tmp/test-mosaic',
sourceDir: '/tmp/test-mosaic',
mode: 'quick',
installAction: 'fresh',
soul: {},
user: {},
tools: {},
runtimes: { detected: [], mcpConfigured: false },
selectedSkills: [],
...overrides,
};
}
describe('soulSetupStage', () => {
it('sets agent name and style in quick mode', async () => {
const p = new HeadlessPrompter({
'What name should agents use?': 'Jarvis',
'Communication style': 'friendly',
});
const state = createState({ mode: 'quick' });
await soulSetupStage(p, state);
expect(state.soul.agentName).toBe('Jarvis');
expect(state.soul.communicationStyle).toBe('friendly');
expect(state.soul.roleDescription).toBe(
'execution partner and visibility engine',
);
});
it('uses defaults in quick mode with no answers', async () => {
const p = new HeadlessPrompter({});
const state = createState({ mode: 'quick' });
await soulSetupStage(p, state);
expect(state.soul.agentName).toBe('Assistant');
expect(state.soul.communicationStyle).toBe('direct');
});
it('skips when install action is keep', async () => {
const p = new HeadlessPrompter({});
const state = createState({ installAction: 'keep' });
state.soul.agentName = 'Existing';
await soulSetupStage(p, state);
expect(state.soul.agentName).toBe('Existing');
});
it('asks for all fields in advanced mode', async () => {
const p = new HeadlessPrompter({
'What name should agents use?': 'Atlas',
'Agent role description': 'memory keeper',
'Communication style': 'formal',
'Accessibility preferences': 'ADHD-friendly',
'Custom guardrails (optional)': 'Never push to main',
});
const state = createState({ mode: 'advanced' });
await soulSetupStage(p, state);
expect(state.soul.agentName).toBe('Atlas');
expect(state.soul.roleDescription).toBe('memory keeper');
expect(state.soul.communicationStyle).toBe('formal');
expect(state.soul.accessibility).toBe('ADHD-friendly');
expect(state.soul.customGuardrails).toBe('Never push to main');
});
});

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
import { userSetupStage } from '../../src/stages/user-setup.js';
import type { WizardState } from '../../src/types.js';
function createState(overrides: Partial<WizardState> = {}): WizardState {
return {
mosaicHome: '/tmp/test-mosaic',
sourceDir: '/tmp/test-mosaic',
mode: 'quick',
installAction: 'fresh',
soul: { communicationStyle: 'direct' },
user: {},
tools: {},
runtimes: { detected: [], mcpConfigured: false },
selectedSkills: [],
...overrides,
};
}
describe('userSetupStage', () => {
it('collects basic info in quick mode', async () => {
const p = new HeadlessPrompter({
'Your name': 'Jason',
'Your pronouns': 'He/Him',
'Your timezone': 'America/Chicago',
});
const state = createState({ mode: 'quick' });
await userSetupStage(p, state);
expect(state.user.userName).toBe('Jason');
expect(state.user.pronouns).toBe('He/Him');
expect(state.user.timezone).toBe('America/Chicago');
expect(state.user.communicationPrefs).toContain('Direct and concise');
});
it('skips when install action is keep', async () => {
const p = new HeadlessPrompter({});
const state = createState({ installAction: 'keep' });
state.user.userName = 'Existing';
await userSetupStage(p, state);
expect(state.user.userName).toBe('Existing');
});
it('derives communication prefs from soul style', async () => {
const p = new HeadlessPrompter({
'Your name': 'Test',
});
const state = createState({
mode: 'quick',
soul: { communicationStyle: 'friendly' },
});
await userSetupStage(p, state);
expect(state.user.communicationPrefs).toContain('Warm and conversational');
});
});

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import {
buildSoulTemplateVars,
buildUserTemplateVars,
buildToolsTemplateVars,
} from '../../src/template/builders.js';
describe('buildSoulTemplateVars', () => {
it('builds direct style correctly', () => {
const vars = buildSoulTemplateVars({
agentName: 'Jarvis',
communicationStyle: 'direct',
});
expect(vars.AGENT_NAME).toBe('Jarvis');
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('Clarity over performance theater');
expect(vars.COMMUNICATION_STYLE).toContain('Be direct, concise, and concrete');
});
it('builds friendly style correctly', () => {
const vars = buildSoulTemplateVars({
communicationStyle: 'friendly',
});
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('Be helpful and approachable');
expect(vars.COMMUNICATION_STYLE).toContain('Be warm and conversational');
});
it('builds formal style correctly', () => {
const vars = buildSoulTemplateVars({
communicationStyle: 'formal',
});
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('Maintain professional, structured');
expect(vars.COMMUNICATION_STYLE).toContain('Use professional, structured language');
});
it('appends accessibility to principles', () => {
const vars = buildSoulTemplateVars({
communicationStyle: 'direct',
accessibility: 'ADHD-friendly chunking',
});
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('6. ADHD-friendly chunking.');
});
it('does not append accessibility when "none"', () => {
const vars = buildSoulTemplateVars({
communicationStyle: 'direct',
accessibility: 'none',
});
expect(vars.BEHAVIORAL_PRINCIPLES).not.toContain('6.');
});
it('formats custom guardrails', () => {
const vars = buildSoulTemplateVars({
customGuardrails: 'Never auto-commit',
});
expect(vars.CUSTOM_GUARDRAILS).toBe('- Never auto-commit');
});
it('uses defaults when config is empty', () => {
const vars = buildSoulTemplateVars({});
expect(vars.AGENT_NAME).toBe('Assistant');
expect(vars.ROLE_DESCRIPTION).toBe('execution partner and visibility engine');
});
});
describe('buildUserTemplateVars', () => {
it('maps all fields', () => {
const vars = buildUserTemplateVars({
userName: 'Jason',
pronouns: 'He/Him',
timezone: 'America/Chicago',
});
expect(vars.USER_NAME).toBe('Jason');
expect(vars.PRONOUNS).toBe('He/Him');
expect(vars.TIMEZONE).toBe('America/Chicago');
});
it('uses defaults for missing fields', () => {
const vars = buildUserTemplateVars({});
expect(vars.PRONOUNS).toBe('They/Them');
expect(vars.TIMEZONE).toBe('UTC');
});
});
describe('buildToolsTemplateVars', () => {
it('builds git providers table', () => {
const vars = buildToolsTemplateVars({
gitProviders: [
{ name: 'GitHub', url: 'https://github.com', cli: 'gh', purpose: 'OSS' },
],
});
expect(vars.GIT_PROVIDERS_TABLE).toContain('| GitHub |');
expect(vars.GIT_PROVIDERS_TABLE).toContain('`gh`');
});
it('uses default table when no providers', () => {
const vars = buildToolsTemplateVars({});
expect(vars.GIT_PROVIDERS_TABLE).toContain('add your git providers here');
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { renderTemplate } from '../../src/template/engine.js';
describe('renderTemplate', () => {
it('replaces all placeholders', () => {
const template = 'You are **{{AGENT_NAME}}**, role: {{ROLE_DESCRIPTION}}';
const result = renderTemplate(template, {
AGENT_NAME: 'Jarvis',
ROLE_DESCRIPTION: 'steward',
});
expect(result).toBe('You are **Jarvis**, role: steward');
});
it('preserves ${ENV_VAR} references', () => {
const template = 'Path: ${HOME}/.config, Agent: {{AGENT_NAME}}';
const result = renderTemplate(template, { AGENT_NAME: 'Test' });
expect(result).toBe('Path: ${HOME}/.config, Agent: Test');
});
it('handles multi-line values', () => {
const template = '{{PRINCIPLES}}';
const result = renderTemplate(template, {
PRINCIPLES: '1. First\n2. Second\n3. Third',
});
expect(result).toBe('1. First\n2. Second\n3. Third');
});
it('replaces unset vars with empty string by default', () => {
const template = 'Before {{MISSING}} After';
const result = renderTemplate(template, {});
expect(result).toBe('Before After');
});
it('throws in strict mode for missing vars', () => {
const template = '{{MISSING}}';
expect(() => renderTemplate(template, {}, { strict: true })).toThrow(
'Template variable not provided: {{MISSING}}',
);
});
it('handles multiple occurrences of same placeholder', () => {
const template = '{{NAME}} says hello, {{NAME}}!';
const result = renderTemplate(template, { NAME: 'Jarvis' });
expect(result).toBe('Jarvis says hello, Jarvis!');
});
it('preserves non-placeholder curly braces', () => {
const template = 'const x = { foo: {{VALUE}} }';
const result = renderTemplate(template, { VALUE: '"bar"' });
expect(result).toBe('const x = { foo: "bar" }');
});
});