Compare commits
1 Commits
v0.0.7
...
b086825edb
| Author | SHA1 | Date | |
|---|---|---|---|
| b086825edb |
@@ -9,7 +9,7 @@ export class ProviderService implements OnModuleInit {
|
|||||||
private registry!: ModelRegistry;
|
private registry!: ModelRegistry;
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
const authStorage = AuthStorage.inMemory();
|
const authStorage = AuthStorage.create();
|
||||||
this.registry = new ModelRegistry(authStorage);
|
this.registry = new ModelRegistry(authStorage);
|
||||||
|
|
||||||
this.registerOllamaProvider();
|
this.registerOllamaProvider();
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { DiscordPlugin } from '@mosaic/discord-plugin';
|
|||||||
import { TelegramPlugin } from '@mosaic/telegram-plugin';
|
import { TelegramPlugin } from '@mosaic/telegram-plugin';
|
||||||
import { PluginService } from './plugin.service.js';
|
import { PluginService } from './plugin.service.js';
|
||||||
import type { IChannelPlugin } from './plugin.interface.js';
|
import type { IChannelPlugin } from './plugin.interface.js';
|
||||||
import { PLUGIN_REGISTRY } from './plugin.tokens.js';
|
|
||||||
|
export const PLUGIN_REGISTRY = Symbol('PLUGIN_REGISTRY');
|
||||||
|
|
||||||
class DiscordChannelPluginAdapter implements IChannelPlugin {
|
class DiscordChannelPluginAdapter implements IChannelPlugin {
|
||||||
readonly name = 'discord';
|
readonly name = 'discord';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { PLUGIN_REGISTRY } from './plugin.tokens.js';
|
import { PLUGIN_REGISTRY } from './plugin.module.js';
|
||||||
import type { IChannelPlugin } from './plugin.interface.js';
|
import type { IChannelPlugin } from './plugin.interface.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const PLUGIN_REGISTRY = Symbol('PLUGIN_REGISTRY');
|
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
**ID:** mvp-20260312
|
**ID:** mvp-20260312
|
||||||
**Statement:** Build Mosaic Stack v0.1.0 — a self-hosted, multi-user AI agent platform with web dashboard, TUI, remote control, shared memory, mission orchestration, and extensible skill/plugin architecture. All TypeScript. Pi as agent harness. Brain as knowledge layer. Queue as coordination backbone.
|
**Statement:** Build Mosaic Stack v0.1.0 — a self-hosted, multi-user AI agent platform with web dashboard, TUI, remote control, shared memory, mission orchestration, and extensible skill/plugin architecture. All TypeScript. Pi as agent harness. Brain as knowledge layer. Queue as coordination backbone.
|
||||||
**Phase:** Execution
|
**Phase:** Execution
|
||||||
**Current Milestone:** Phase 7: Polish & Beta (v0.1.0)
|
**Current Milestone:** Phase 6: CLI & Tools (v0.0.7)
|
||||||
**Progress:** 7 / 8 milestones
|
**Progress:** 6 / 8 milestones
|
||||||
**Status:** active
|
**Status:** active
|
||||||
**Last Updated:** 2026-03-14 UTC
|
**Last Updated:** 2026-03-14 UTC
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
| 3 | ms-160 | Phase 3: Web Dashboard (v0.0.4) | done | — | — | 2026-03-12 | 2026-03-13 |
|
| 3 | ms-160 | Phase 3: Web Dashboard (v0.0.4) | done | — | — | 2026-03-12 | 2026-03-13 |
|
||||||
| 4 | ms-161 | Phase 4: Memory & Intelligence (v0.0.5) | done | — | — | 2026-03-13 | 2026-03-13 |
|
| 4 | ms-161 | Phase 4: Memory & Intelligence (v0.0.5) | done | — | — | 2026-03-13 | 2026-03-13 |
|
||||||
| 5 | ms-162 | Phase 5: Remote Control (v0.0.6) | done | — | #99 | 2026-03-14 | 2026-03-14 |
|
| 5 | ms-162 | Phase 5: Remote Control (v0.0.6) | done | — | #99 | 2026-03-14 | 2026-03-14 |
|
||||||
| 6 | ms-163 | Phase 6: CLI & Tools (v0.0.7) | done | — | #104 | 2026-03-14 | 2026-03-14 |
|
| 6 | ms-163 | Phase 6: CLI & Tools (v0.0.7) | not-started | — | — | — | — |
|
||||||
| 7 | ms-164 | Phase 7: Polish & Beta (v0.1.0) | not-started | — | — | — | — |
|
| 7 | ms-164 | Phase 7: Polish & Beta (v0.1.0) | not-started | — | — | — | — |
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
|
||||||
| id | status | milestone | description | pr | notes |
|
| id | status | milestone | description | pr | notes |
|
||||||
| ------ | ----------- | --------- | ------------------------------------------------------------- | ---- | ----- |
|
| ------ | ----------- | --------- | ------------------------------------------------------------- | --- | ----- |
|
||||||
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
|
| P0-001 | done | Phase 0 | Scaffold monorepo | #60 | #1 |
|
||||||
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 |
|
| P0-002 | done | Phase 0 | @mosaic/types — migrate and extend shared types | #65 | #2 |
|
||||||
| P0-003 | done | Phase 0 | @mosaic/db — Drizzle schema and PG connection | #67 | #3 |
|
| P0-003 | done | Phase 0 | @mosaic/db — Drizzle schema and PG connection | #67 | #3 |
|
||||||
@@ -49,12 +49,12 @@
|
|||||||
| P5-003 | done | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 |
|
| P5-003 | done | Phase 5 | @mosaic/telegram-plugin — Telegraf bot + channel plugin | — | #43 |
|
||||||
| P5-004 | done | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 |
|
| P5-004 | done | Phase 5 | SSO — Authentik OIDC adapter end-to-end | — | #44 |
|
||||||
| P5-005 | done | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | #99 | #45 |
|
| P5-005 | done | Phase 5 | Verify Phase 5 — Discord + Telegram + SSO working | #99 | #45 |
|
||||||
| P6-001 | done | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | #104 | #46 |
|
| P6-001 | not-started | Phase 6 | @mosaic/cli — unified CLI binary + subcommands | — | #46 |
|
||||||
| P6-002 | done | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | #101 | #47 |
|
| P6-002 | not-started | Phase 6 | @mosaic/prdy — migrate PRD wizard from v0 | — | #47 |
|
||||||
| P6-003 | done | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | #100 | #48 |
|
| P6-003 | not-started | Phase 6 | @mosaic/quality-rails — migrate scaffolder from v0 | — | #48 |
|
||||||
| P6-004 | done | Phase 6 | @mosaic/mosaic — install wizard for v1 | #103 | #49 |
|
| P6-004 | not-started | Phase 6 | @mosaic/mosaic — install wizard for v1 | — | #49 |
|
||||||
| P6-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
|
| P6-005 | done | Phase 6 | Pi TUI integration — mosaic tui | #61 | #50 |
|
||||||
| P6-006 | done | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
| P6-006 | not-started | Phase 6 | Verify Phase 6 — CLI functional, all subcommands | — | #51 |
|
||||||
| P7-001 | not-started | Phase 7 | MCP endpoint hardening — streamable HTTP transport | — | #52 |
|
| P7-001 | not-started | Phase 7 | MCP endpoint hardening — streamable HTTP transport | — | #52 |
|
||||||
| P7-002 | not-started | Phase 7 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
|
| P7-002 | not-started | Phase 7 | Additional SSO providers — WorkOS + Keycloak | — | #53 |
|
||||||
| P7-003 | not-started | Phase 7 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
| P7-003 | not-started | Phase 7 | Additional LLM providers — Codex, Z.ai, LM Studio, llama.cpp | — | #54 |
|
||||||
|
|||||||
@@ -86,21 +86,6 @@ User confirmed: start the planning gate.
|
|||||||
- SSO/Authentik OIDC adapter was fully wired
|
- SSO/Authentik OIDC adapter was fully wired
|
||||||
- All three quality gates passing
|
- All three quality gates passing
|
||||||
|
|
||||||
### Session 11 (continued) — Phase 6 completion
|
|
||||||
|
|
||||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
|
||||||
| ------- | ---------- | --------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| 11 | 2026-03-14 | Phase 6 | P6-002, P6-003, P6-004, P6-001, P6-006 | Full CLI & Tools migration. PRs #100-#104 merged. Also fixed 2 gateway startup bugs (PR #102). Phase 6 complete. |
|
|
||||||
|
|
||||||
**Phase 6 details:**
|
|
||||||
|
|
||||||
- P6-002: @mosaic/prdy migrated from v0 (~400 LOC). PR #101.
|
|
||||||
- P6-003: @mosaic/quality-rails migrated from v0 (~500 LOC). PR #100.
|
|
||||||
- P6-004: @mosaic/mosaic wizard migrated from v0 (2272 LOC, 28 files). PR #103.
|
|
||||||
- P6-001: CLI subcommands wired — tui, prdy, quality-rails, wizard all working. PR #104.
|
|
||||||
- BUG-1: PLUGIN_REGISTRY circular import fixed via plugin.tokens.ts. PR #102.
|
|
||||||
- BUG-2: AuthStorage.create() → .inMemory() to prevent silent exit. PR #102.
|
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
(none at this time)
|
(none at this time)
|
||||||
|
|||||||
@@ -21,9 +21,6 @@
|
|||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaic/mosaic": "workspace:^",
|
|
||||||
"@mosaic/prdy": "workspace:^",
|
|
||||||
"@mosaic/quality-rails": "workspace:^",
|
|
||||||
"ink": "^5.0.0",
|
"ink": "^5.0.0",
|
||||||
"ink-text-input": "^6.0.0",
|
"ink-text-input": "^6.0.0",
|
||||||
"ink-spinner": "^5.0.0",
|
"ink-spinner": "^5.0.0",
|
||||||
@@ -32,7 +29,6 @@
|
|||||||
"commander": "^13.0.0"
|
"commander": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"tsx": "^4.0.0",
|
"tsx": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { buildPrdyCli } from '@mosaic/prdy';
|
|
||||||
import { createQualityRailsCli } from '@mosaic/quality-rails';
|
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
@@ -27,85 +25,4 @@ program
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// prdy subcommand
|
|
||||||
// buildPrdyCli() returns a wrapper Command; extract the 'prdy' subcommand from it.
|
|
||||||
// Type cast is required because @mosaic/prdy uses commander@12 while @mosaic/cli uses commander@13.
|
|
||||||
const prdyWrapper = buildPrdyCli();
|
|
||||||
const prdyCmd = prdyWrapper.commands.find((c) => c.name() === 'prdy');
|
|
||||||
if (prdyCmd !== undefined) {
|
|
||||||
program.addCommand(prdyCmd as unknown as Command);
|
|
||||||
}
|
|
||||||
|
|
||||||
// quality-rails subcommand
|
|
||||||
// createQualityRailsCli() returns a wrapper Command; extract the 'quality-rails' subcommand.
|
|
||||||
const qrWrapper = createQualityRailsCli();
|
|
||||||
const qrCmd = qrWrapper.commands.find((c) => c.name() === 'quality-rails');
|
|
||||||
if (qrCmd !== undefined) {
|
|
||||||
program.addCommand(qrCmd as unknown as Command);
|
|
||||||
}
|
|
||||||
|
|
||||||
// wizard subcommand — wraps @mosaic/mosaic installation wizard
|
|
||||||
program
|
|
||||||
.command('wizard')
|
|
||||||
.description('Run the Mosaic installation wizard')
|
|
||||||
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
|
|
||||||
.option('--source-dir <path>', 'Source directory for framework files')
|
|
||||||
.option('--mosaic-home <path>', 'Target config directory')
|
|
||||||
.option('--name <name>', 'Agent name')
|
|
||||||
.option('--role <description>', 'Agent role description')
|
|
||||||
.option('--style <style>', 'Communication style: direct|friendly|formal')
|
|
||||||
.option('--accessibility <prefs>', 'Accessibility preferences')
|
|
||||||
.option('--guardrails <rules>', 'Custom guardrails')
|
|
||||||
.option('--user-name <name>', 'Your name')
|
|
||||||
.option('--pronouns <pronouns>', 'Your pronouns')
|
|
||||||
.option('--timezone <tz>', 'Your timezone')
|
|
||||||
.action(async (opts: Record<string, string | boolean | undefined>) => {
|
|
||||||
// Dynamic import to avoid loading wizard deps for other commands
|
|
||||||
const {
|
|
||||||
runWizard,
|
|
||||||
ClackPrompter,
|
|
||||||
HeadlessPrompter,
|
|
||||||
createConfigService,
|
|
||||||
WizardCancelledError,
|
|
||||||
DEFAULT_MOSAIC_HOME,
|
|
||||||
} = await import('@mosaic/mosaic');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mosaicHome = (opts['mosaicHome'] as string | undefined) ?? DEFAULT_MOSAIC_HOME;
|
|
||||||
const sourceDir = (opts['sourceDir'] as string | undefined) ?? mosaicHome;
|
|
||||||
|
|
||||||
const prompter = opts['nonInteractive'] ? new HeadlessPrompter() : new ClackPrompter();
|
|
||||||
|
|
||||||
const configService = createConfigService(mosaicHome, sourceDir);
|
|
||||||
|
|
||||||
await runWizard({
|
|
||||||
mosaicHome,
|
|
||||||
sourceDir,
|
|
||||||
prompter,
|
|
||||||
configService,
|
|
||||||
cliOverrides: {
|
|
||||||
soul: {
|
|
||||||
agentName: opts['name'] as string | undefined,
|
|
||||||
roleDescription: opts['role'] as string | undefined,
|
|
||||||
communicationStyle: opts['style'] as 'direct' | 'friendly' | 'formal' | undefined,
|
|
||||||
accessibility: opts['accessibility'] as string | undefined,
|
|
||||||
customGuardrails: opts['guardrails'] as string | undefined,
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
userName: opts['userName'] as string | undefined,
|
|
||||||
pronouns: opts['pronouns'] as string | undefined,
|
|
||||||
timezone: opts['timezone'] as string | undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof WizardCancelledError) {
|
|
||||||
console.log('\nWizard cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
console.error('Wizard failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
program.parse();
|
program.parse();
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/mosaic",
|
"name": "@mosaic/mosaic",
|
||||||
"version": "0.1.0",
|
"version": "0.0.0",
|
||||||
"description": "Mosaic installation wizard",
|
|
||||||
"type": "module",
|
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"bin": {
|
|
||||||
"mosaic-wizard": "dist/index.js"
|
|
||||||
},
|
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
@@ -20,15 +15,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
|
||||||
"@clack/prompts": "^0.9.1",
|
|
||||||
"commander": "^12.1.0",
|
|
||||||
"picocolors": "^1.1.1",
|
|
||||||
"yaml": "^2.6.1",
|
|
||||||
"zod": "^3.23.8"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
|
|
||||||
import { FileConfigAdapter } from './file-adapter.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ConfigService interface — abstracts config read/write operations.
|
|
||||||
* Currently backed by FileConfigAdapter (writes .md files from templates).
|
|
||||||
* Designed for future swap to SqliteConfigAdapter or PostgresConfigAdapter.
|
|
||||||
*/
|
|
||||||
export interface ConfigService {
|
|
||||||
readSoul(): Promise<SoulConfig>;
|
|
||||||
readUser(): Promise<UserConfig>;
|
|
||||||
readTools(): Promise<ToolsConfig>;
|
|
||||||
|
|
||||||
writeSoul(config: SoulConfig): Promise<void>;
|
|
||||||
writeUser(config: UserConfig): Promise<void>;
|
|
||||||
writeTools(config: ToolsConfig): Promise<void>;
|
|
||||||
|
|
||||||
syncFramework(action: InstallAction): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createConfigService(mosaicHome: string, sourceDir: string): ConfigService {
|
|
||||||
return new FileConfigAdapter(mosaicHome, sourceDir);
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { readFileSync, existsSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import type { ConfigService } from './config-service.js';
|
|
||||||
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
|
|
||||||
import { soulSchema, userSchema, toolsSchema } from './schemas.js';
|
|
||||||
import { renderTemplate } from '../template/engine.js';
|
|
||||||
import {
|
|
||||||
buildSoulTemplateVars,
|
|
||||||
buildUserTemplateVars,
|
|
||||||
buildToolsTemplateVars,
|
|
||||||
} from '../template/builders.js';
|
|
||||||
import { atomicWrite, backupFile, syncDirectory } from '../platform/file-ops.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a SoulConfig from an existing SOUL.md file.
|
|
||||||
*/
|
|
||||||
function parseSoulFromMarkdown(content: string): SoulConfig {
|
|
||||||
const config: SoulConfig = {};
|
|
||||||
|
|
||||||
const nameMatch = content.match(/You are \*\*(.+?)\*\*/);
|
|
||||||
if (nameMatch?.[1]) config.agentName = nameMatch[1];
|
|
||||||
|
|
||||||
const roleMatch = content.match(/Role identity: (.+)/);
|
|
||||||
if (roleMatch?.[1]) config.roleDescription = roleMatch[1];
|
|
||||||
|
|
||||||
if (content.includes('Be direct, concise')) {
|
|
||||||
config.communicationStyle = 'direct';
|
|
||||||
} else if (content.includes('Be warm and conversational')) {
|
|
||||||
config.communicationStyle = 'friendly';
|
|
||||||
} else if (content.includes('Use professional, structured')) {
|
|
||||||
config.communicationStyle = 'formal';
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a UserConfig from an existing USER.md file.
|
|
||||||
*/
|
|
||||||
function parseUserFromMarkdown(content: string): UserConfig {
|
|
||||||
const config: UserConfig = {};
|
|
||||||
|
|
||||||
const nameMatch = content.match(/\*\*Name:\*\* (.+)/);
|
|
||||||
if (nameMatch?.[1]) config.userName = nameMatch[1];
|
|
||||||
|
|
||||||
const pronounsMatch = content.match(/\*\*Pronouns:\*\* (.+)/);
|
|
||||||
if (pronounsMatch?.[1]) config.pronouns = pronounsMatch[1];
|
|
||||||
|
|
||||||
const tzMatch = content.match(/\*\*Timezone:\*\* (.+)/);
|
|
||||||
if (tzMatch?.[1]) config.timezone = tzMatch[1];
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a ToolsConfig from an existing TOOLS.md file.
|
|
||||||
*/
|
|
||||||
function parseToolsFromMarkdown(content: string): ToolsConfig {
|
|
||||||
const config: ToolsConfig = {};
|
|
||||||
|
|
||||||
const credsMatch = content.match(/\*\*Location:\*\* (.+)/);
|
|
||||||
if (credsMatch?.[1]) config.credentialsLocation = credsMatch[1];
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileConfigAdapter implements ConfigService {
|
|
||||||
constructor(
|
|
||||||
private mosaicHome: string,
|
|
||||||
private sourceDir: string,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async readSoul(): Promise<SoulConfig> {
|
|
||||||
const path = join(this.mosaicHome, 'SOUL.md');
|
|
||||||
if (!existsSync(path)) return {};
|
|
||||||
return parseSoulFromMarkdown(readFileSync(path, 'utf-8'));
|
|
||||||
}
|
|
||||||
|
|
||||||
async readUser(): Promise<UserConfig> {
|
|
||||||
const path = join(this.mosaicHome, 'USER.md');
|
|
||||||
if (!existsSync(path)) return {};
|
|
||||||
return parseUserFromMarkdown(readFileSync(path, 'utf-8'));
|
|
||||||
}
|
|
||||||
|
|
||||||
async readTools(): Promise<ToolsConfig> {
|
|
||||||
const path = join(this.mosaicHome, 'TOOLS.md');
|
|
||||||
if (!existsSync(path)) return {};
|
|
||||||
return parseToolsFromMarkdown(readFileSync(path, 'utf-8'));
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeSoul(config: SoulConfig): Promise<void> {
|
|
||||||
const validated = soulSchema.parse(config);
|
|
||||||
const templatePath = this.findTemplate('SOUL.md.template');
|
|
||||||
if (!templatePath) return;
|
|
||||||
|
|
||||||
const template = readFileSync(templatePath, 'utf-8');
|
|
||||||
const vars = buildSoulTemplateVars(validated);
|
|
||||||
const output = renderTemplate(template, vars);
|
|
||||||
|
|
||||||
const outPath = join(this.mosaicHome, 'SOUL.md');
|
|
||||||
backupFile(outPath);
|
|
||||||
atomicWrite(outPath, output);
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeUser(config: UserConfig): Promise<void> {
|
|
||||||
const validated = userSchema.parse(config);
|
|
||||||
const templatePath = this.findTemplate('USER.md.template');
|
|
||||||
if (!templatePath) return;
|
|
||||||
|
|
||||||
const template = readFileSync(templatePath, 'utf-8');
|
|
||||||
const vars = buildUserTemplateVars(validated);
|
|
||||||
const output = renderTemplate(template, vars);
|
|
||||||
|
|
||||||
const outPath = join(this.mosaicHome, 'USER.md');
|
|
||||||
backupFile(outPath);
|
|
||||||
atomicWrite(outPath, output);
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeTools(config: ToolsConfig): Promise<void> {
|
|
||||||
const validated = toolsSchema.parse(config);
|
|
||||||
const templatePath = this.findTemplate('TOOLS.md.template');
|
|
||||||
if (!templatePath) return;
|
|
||||||
|
|
||||||
const template = readFileSync(templatePath, 'utf-8');
|
|
||||||
const vars = buildToolsTemplateVars(validated);
|
|
||||||
const output = renderTemplate(template, vars);
|
|
||||||
|
|
||||||
const outPath = join(this.mosaicHome, 'TOOLS.md');
|
|
||||||
backupFile(outPath);
|
|
||||||
atomicWrite(outPath, output);
|
|
||||||
}
|
|
||||||
|
|
||||||
async syncFramework(action: InstallAction): Promise<void> {
|
|
||||||
const preservePaths =
|
|
||||||
action === 'keep' || action === 'reconfigure'
|
|
||||||
? ['SOUL.md', 'USER.md', 'TOOLS.md', 'memory']
|
|
||||||
: [];
|
|
||||||
|
|
||||||
syncDirectory(this.sourceDir, this.mosaicHome, {
|
|
||||||
preserve: preservePaths,
|
|
||||||
excludeGit: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Look for template in source dir first, then mosaic home.
|
|
||||||
*/
|
|
||||||
private findTemplate(name: string): string | null {
|
|
||||||
const candidates = [
|
|
||||||
join(this.sourceDir, 'templates', name),
|
|
||||||
join(this.mosaicHome, 'templates', name),
|
|
||||||
];
|
|
||||||
for (const path of candidates) {
|
|
||||||
if (existsSync(path)) return path;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const communicationStyleSchema = z.enum(['direct', 'friendly', 'formal']).default('direct');
|
|
||||||
|
|
||||||
export const soulSchema = z
|
|
||||||
.object({
|
|
||||||
agentName: z.string().min(1).max(50).default('Assistant'),
|
|
||||||
roleDescription: z.string().default('execution partner and visibility engine'),
|
|
||||||
communicationStyle: communicationStyleSchema,
|
|
||||||
accessibility: z.string().default('none'),
|
|
||||||
customGuardrails: z.string().default(''),
|
|
||||||
})
|
|
||||||
.partial();
|
|
||||||
|
|
||||||
export const gitProviderSchema = z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
url: z.string().min(1),
|
|
||||||
cli: z.string().min(1),
|
|
||||||
purpose: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const userSchema = z
|
|
||||||
.object({
|
|
||||||
userName: z.string().default(''),
|
|
||||||
pronouns: z.string().default('They/Them'),
|
|
||||||
timezone: z.string().default('UTC'),
|
|
||||||
background: z.string().default('(not configured)'),
|
|
||||||
accessibilitySection: z
|
|
||||||
.string()
|
|
||||||
.default('(No specific accommodations configured. Edit this section to add any.)'),
|
|
||||||
communicationPrefs: z.string().default(''),
|
|
||||||
personalBoundaries: z.string().default('(Edit this section to add any personal boundaries.)'),
|
|
||||||
projectsTable: z.string().default(''),
|
|
||||||
})
|
|
||||||
.partial();
|
|
||||||
|
|
||||||
export const toolsSchema = z
|
|
||||||
.object({
|
|
||||||
gitProviders: z.array(gitProviderSchema).default([]),
|
|
||||||
credentialsLocation: z.string().default('none'),
|
|
||||||
customToolsSection: z.string().default(''),
|
|
||||||
})
|
|
||||||
.partial();
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { homedir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
export const VERSION = '0.1.0';
|
|
||||||
|
|
||||||
export const DEFAULT_MOSAIC_HOME = join(homedir(), '.config', 'mosaic');
|
|
||||||
|
|
||||||
export const DEFAULTS = {
|
|
||||||
agentName: 'Assistant',
|
|
||||||
roleDescription: 'execution partner and visibility engine',
|
|
||||||
communicationStyle: 'direct' as const,
|
|
||||||
pronouns: 'They/Them',
|
|
||||||
timezone: 'UTC',
|
|
||||||
background: '(not configured)',
|
|
||||||
accessibilitySection: '(No specific accommodations configured. Edit this section to add any.)',
|
|
||||||
personalBoundaries: '(Edit this section to add any personal boundaries.)',
|
|
||||||
projectsTable: `| Project | Stack | Registry |
|
|
||||||
|---------|-------|----------|
|
|
||||||
| (none configured) | | |`,
|
|
||||||
credentialsLocation: 'none',
|
|
||||||
customToolsSection: `## Custom Tools
|
|
||||||
|
|
||||||
(Add any machine-specific tools, scripts, or workflows here.)`,
|
|
||||||
gitProvidersTable: `| Instance | URL | CLI | Purpose |
|
|
||||||
|----------|-----|-----|---------|
|
|
||||||
| (add your git providers here) | | | |`,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RECOMMENDED_SKILLS = new Set([
|
|
||||||
'brainstorming',
|
|
||||||
'code-review-excellence',
|
|
||||||
'lint',
|
|
||||||
'systematic-debugging',
|
|
||||||
'verification-before-completion',
|
|
||||||
'writing-plans',
|
|
||||||
'executing-plans',
|
|
||||||
'architecture-patterns',
|
|
||||||
]);
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export class WizardCancelledError extends Error {
|
|
||||||
override name = 'WizardCancelledError';
|
|
||||||
constructor() {
|
|
||||||
super('Wizard cancelled by user');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ValidationError extends Error {
|
|
||||||
override name = 'ValidationError';
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TemplateError extends Error {
|
|
||||||
override name = 'TemplateError';
|
|
||||||
constructor(templatePath: string, message: string) {
|
|
||||||
super(`Template error in ${templatePath}: ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +1 @@
|
|||||||
#!/usr/bin/env node
|
export const VERSION = '0.0.0';
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { ClackPrompter } from './prompter/clack-prompter.js';
|
|
||||||
import { HeadlessPrompter } from './prompter/headless-prompter.js';
|
|
||||||
import { createConfigService } from './config/config-service.js';
|
|
||||||
import { runWizard } from './wizard.js';
|
|
||||||
import { WizardCancelledError } from './errors.js';
|
|
||||||
import { VERSION, DEFAULT_MOSAIC_HOME } from './constants.js';
|
|
||||||
import type { CommunicationStyle } from './types.js';
|
|
||||||
|
|
||||||
export { VERSION, DEFAULT_MOSAIC_HOME };
|
|
||||||
export { runWizard } from './wizard.js';
|
|
||||||
export { ClackPrompter } from './prompter/clack-prompter.js';
|
|
||||||
export { HeadlessPrompter } from './prompter/headless-prompter.js';
|
|
||||||
export { createConfigService } from './config/config-service.js';
|
|
||||||
export { WizardCancelledError } from './errors.js';
|
|
||||||
|
|
||||||
const program = new Command()
|
|
||||||
.name('mosaic-wizard')
|
|
||||||
.description('Mosaic Installation Wizard')
|
|
||||||
.version(VERSION);
|
|
||||||
|
|
||||||
program
|
|
||||||
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
|
|
||||||
.option('--source-dir <path>', 'Source directory for framework files')
|
|
||||||
.option('--mosaic-home <path>', 'Target config directory', DEFAULT_MOSAIC_HOME)
|
|
||||||
// SOUL.md overrides
|
|
||||||
.option('--name <name>', 'Agent name')
|
|
||||||
.option('--role <description>', 'Agent role description')
|
|
||||||
.option('--style <style>', 'Communication style: direct|friendly|formal')
|
|
||||||
.option('--accessibility <prefs>', 'Accessibility preferences')
|
|
||||||
.option('--guardrails <rules>', 'Custom guardrails')
|
|
||||||
// USER.md overrides
|
|
||||||
.option('--user-name <name>', 'Your name')
|
|
||||||
.option('--pronouns <pronouns>', 'Your pronouns')
|
|
||||||
.option('--timezone <tz>', 'Your timezone')
|
|
||||||
.action(async (opts: Record<string, string | boolean | undefined>) => {
|
|
||||||
try {
|
|
||||||
const mosaicHome = (opts['mosaicHome'] as string) ?? DEFAULT_MOSAIC_HOME;
|
|
||||||
const sourceDir = (opts['sourceDir'] as string | undefined) ?? mosaicHome;
|
|
||||||
|
|
||||||
const prompter = opts['nonInteractive'] ? new HeadlessPrompter() : new ClackPrompter();
|
|
||||||
|
|
||||||
const configService = createConfigService(mosaicHome, sourceDir);
|
|
||||||
|
|
||||||
const style = opts['style'] as CommunicationStyle | undefined;
|
|
||||||
|
|
||||||
await runWizard({
|
|
||||||
mosaicHome,
|
|
||||||
sourceDir,
|
|
||||||
prompter,
|
|
||||||
configService,
|
|
||||||
cliOverrides: {
|
|
||||||
soul: {
|
|
||||||
agentName: opts['name'] as string | undefined,
|
|
||||||
roleDescription: opts['role'] as string | undefined,
|
|
||||||
communicationStyle: style,
|
|
||||||
accessibility: opts['accessibility'] as string | undefined,
|
|
||||||
customGuardrails: opts['guardrails'] as string | undefined,
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
userName: opts['userName'] as string | undefined,
|
|
||||||
pronouns: opts['pronouns'] as string | undefined,
|
|
||||||
timezone: opts['timezone'] as string | undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof WizardCancelledError) {
|
|
||||||
console.log('\nWizard cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
console.error('Wizard failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const entryPath = process.argv[1] ? resolve(process.argv[1]) : '';
|
|
||||||
if (entryPath.length > 0 && entryPath === fileURLToPath(import.meta.url)) {
|
|
||||||
program.parse();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { homedir, platform } from 'node:os';
|
|
||||||
|
|
||||||
export type ShellType = 'zsh' | 'bash' | 'fish' | 'powershell' | 'unknown';
|
|
||||||
|
|
||||||
export function detectShell(): ShellType {
|
|
||||||
const shell = process.env['SHELL'] ?? '';
|
|
||||||
if (shell.includes('zsh')) return 'zsh';
|
|
||||||
if (shell.includes('bash')) return 'bash';
|
|
||||||
if (shell.includes('fish')) return 'fish';
|
|
||||||
if (platform() === 'win32') return 'powershell';
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getShellProfilePath(): string | null {
|
|
||||||
const home = homedir();
|
|
||||||
|
|
||||||
if (platform() === 'win32') {
|
|
||||||
return join(home, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1');
|
|
||||||
}
|
|
||||||
|
|
||||||
const shell = detectShell();
|
|
||||||
switch (shell) {
|
|
||||||
case 'zsh': {
|
|
||||||
const zdotdir = process.env['ZDOTDIR'] ?? home;
|
|
||||||
return join(zdotdir, '.zshrc');
|
|
||||||
}
|
|
||||||
case 'bash': {
|
|
||||||
const bashrc = join(home, '.bashrc');
|
|
||||||
if (existsSync(bashrc)) return bashrc;
|
|
||||||
return join(home, '.profile');
|
|
||||||
}
|
|
||||||
case 'fish':
|
|
||||||
return join(home, '.config', 'fish', 'config.fish');
|
|
||||||
default:
|
|
||||||
return join(home, '.profile');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import {
|
|
||||||
readFileSync,
|
|
||||||
writeFileSync,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
copyFileSync,
|
|
||||||
renameSync,
|
|
||||||
readdirSync,
|
|
||||||
unlinkSync,
|
|
||||||
statSync,
|
|
||||||
} from 'node:fs';
|
|
||||||
import { dirname, join, relative } from 'node:path';
|
|
||||||
|
|
||||||
const MAX_BACKUPS = 3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atomic write: write to temp file, then rename.
|
|
||||||
* Creates parent directories as needed.
|
|
||||||
*/
|
|
||||||
export function atomicWrite(filePath: string, content: string): void {
|
|
||||||
mkdirSync(dirname(filePath), { recursive: true });
|
|
||||||
const tmpPath = `${filePath}.tmp-${process.pid.toString()}`;
|
|
||||||
writeFileSync(tmpPath, content, 'utf-8');
|
|
||||||
renameSync(tmpPath, filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a backup of a file before overwriting.
|
|
||||||
* Rotates backups to keep at most MAX_BACKUPS.
|
|
||||||
*/
|
|
||||||
export function backupFile(filePath: string): string | null {
|
|
||||||
if (!existsSync(filePath)) return null;
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '').replace('T', '-').slice(0, 19);
|
|
||||||
const backupPath = `${filePath}.bak-${timestamp}`;
|
|
||||||
copyFileSync(filePath, backupPath);
|
|
||||||
rotateBackups(filePath);
|
|
||||||
return backupPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rotateBackups(filePath: string): void {
|
|
||||||
const dir = dirname(filePath);
|
|
||||||
const baseName = filePath.split('/').pop() ?? '';
|
|
||||||
const prefix = `${baseName}.bak-`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const backups = readdirSync(dir)
|
|
||||||
.filter((f: string) => f.startsWith(prefix))
|
|
||||||
.sort()
|
|
||||||
.reverse();
|
|
||||||
|
|
||||||
for (let i = MAX_BACKUPS; i < backups.length; i++) {
|
|
||||||
const backup = backups[i];
|
|
||||||
if (backup !== undefined) {
|
|
||||||
unlinkSync(join(dir, backup));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Non-fatal: backup rotation failure doesn't block writes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync a source directory to a target, with optional preserve paths.
|
|
||||||
* Replaces the rsync/cp logic from install.sh.
|
|
||||||
*/
|
|
||||||
export function syncDirectory(
|
|
||||||
source: string,
|
|
||||||
target: string,
|
|
||||||
options: { preserve?: string[]; excludeGit?: boolean } = {},
|
|
||||||
): void {
|
|
||||||
const preserveSet = new Set(options.preserve ?? []);
|
|
||||||
|
|
||||||
// Collect files from source
|
|
||||||
function copyRecursive(src: string, dest: string, relBase: string): void {
|
|
||||||
if (!existsSync(src)) return;
|
|
||||||
|
|
||||||
const stat = statSync(src);
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
const relPath = relative(relBase, src);
|
|
||||||
|
|
||||||
// Skip .git
|
|
||||||
if (options.excludeGit && relPath === '.git') return;
|
|
||||||
|
|
||||||
// Skip preserved paths at top level
|
|
||||||
if (preserveSet.has(relPath) && existsSync(dest)) return;
|
|
||||||
|
|
||||||
mkdirSync(dest, { recursive: true });
|
|
||||||
for (const entry of readdirSync(src)) {
|
|
||||||
copyRecursive(join(src, entry), join(dest, entry), relBase);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const relPath = relative(relBase, src);
|
|
||||||
|
|
||||||
// Skip preserved files at top level
|
|
||||||
if (preserveSet.has(relPath) && existsSync(dest)) return;
|
|
||||||
|
|
||||||
mkdirSync(dirname(dest), { recursive: true });
|
|
||||||
copyFileSync(src, dest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
copyRecursive(source, target, source);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely read a file, returning null if it doesn't exist.
|
|
||||||
*/
|
|
||||||
export function safeReadFile(filePath: string): string | null {
|
|
||||||
try {
|
|
||||||
return readFileSync(filePath, 'utf-8');
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import { WizardCancelledError } from '../errors.js';
|
|
||||||
import type {
|
|
||||||
WizardPrompter,
|
|
||||||
SelectOption,
|
|
||||||
MultiSelectOption,
|
|
||||||
ProgressHandle,
|
|
||||||
} from './interface.js';
|
|
||||||
|
|
||||||
function guardCancel<T>(value: T | symbol): T {
|
|
||||||
if (p.isCancel(value)) {
|
|
||||||
throw new WizardCancelledError();
|
|
||||||
}
|
|
||||||
return value as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ClackPrompter implements WizardPrompter {
|
|
||||||
intro(message: string): void {
|
|
||||||
p.intro(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
outro(message: string): void {
|
|
||||||
p.outro(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
note(message: string, title?: string): void {
|
|
||||||
p.note(message, title);
|
|
||||||
}
|
|
||||||
|
|
||||||
log(message: string): void {
|
|
||||||
p.log.info(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
warn(message: string): void {
|
|
||||||
p.log.warn(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
async text(opts: {
|
|
||||||
message: string;
|
|
||||||
placeholder?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
validate?: (value: string) => string | void;
|
|
||||||
}): Promise<string> {
|
|
||||||
const validate = opts.validate
|
|
||||||
? (v: string) => {
|
|
||||||
const r = opts.validate!(v);
|
|
||||||
return r === undefined ? undefined : r;
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
const result = await p.text({
|
|
||||||
message: opts.message,
|
|
||||||
placeholder: opts.placeholder,
|
|
||||||
defaultValue: opts.defaultValue,
|
|
||||||
validate,
|
|
||||||
});
|
|
||||||
return guardCancel(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
async confirm(opts: { message: string; initialValue?: boolean }): Promise<boolean> {
|
|
||||||
const result = await p.confirm({
|
|
||||||
message: opts.message,
|
|
||||||
initialValue: opts.initialValue,
|
|
||||||
});
|
|
||||||
return guardCancel(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
async select<T>(opts: {
|
|
||||||
message: string;
|
|
||||||
options: SelectOption<T>[];
|
|
||||||
initialValue?: T;
|
|
||||||
}): Promise<T> {
|
|
||||||
const clackOptions = opts.options.map((o) => ({
|
|
||||||
value: o.value as T,
|
|
||||||
label: o.label,
|
|
||||||
hint: o.hint,
|
|
||||||
}));
|
|
||||||
const result = await p.select({
|
|
||||||
message: opts.message,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- clack Option conditional type needs concrete Primitive
|
|
||||||
options: clackOptions as any,
|
|
||||||
initialValue: opts.initialValue,
|
|
||||||
});
|
|
||||||
return guardCancel(result) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
async multiselect<T>(opts: {
|
|
||||||
message: string;
|
|
||||||
options: MultiSelectOption<T>[];
|
|
||||||
required?: boolean;
|
|
||||||
}): Promise<T[]> {
|
|
||||||
const clackOptions = opts.options.map((o) => ({
|
|
||||||
value: o.value as T,
|
|
||||||
label: o.label,
|
|
||||||
hint: o.hint,
|
|
||||||
}));
|
|
||||||
const result = await p.multiselect({
|
|
||||||
message: opts.message,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
options: clackOptions as any,
|
|
||||||
required: opts.required,
|
|
||||||
initialValues: opts.options.filter((o) => o.selected).map((o) => o.value),
|
|
||||||
});
|
|
||||||
return guardCancel(result) as T[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async groupMultiselect<T>(opts: {
|
|
||||||
message: string;
|
|
||||||
options: Record<string, MultiSelectOption<T>[]>;
|
|
||||||
required?: boolean;
|
|
||||||
}): Promise<T[]> {
|
|
||||||
const grouped: Record<string, { value: T; label: string; hint?: string }[]> = {};
|
|
||||||
for (const [group, items] of Object.entries(opts.options)) {
|
|
||||||
grouped[group] = items.map((o) => ({
|
|
||||||
value: o.value as T,
|
|
||||||
label: o.label,
|
|
||||||
hint: o.hint,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
const result = await p.groupMultiselect({
|
|
||||||
message: opts.message,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
options: grouped as any,
|
|
||||||
required: opts.required,
|
|
||||||
});
|
|
||||||
return guardCancel(result) as T[];
|
|
||||||
}
|
|
||||||
|
|
||||||
spinner(): ProgressHandle {
|
|
||||||
const s = p.spinner();
|
|
||||||
let started = false;
|
|
||||||
return {
|
|
||||||
update(message: string) {
|
|
||||||
if (!started) {
|
|
||||||
s.start(message);
|
|
||||||
started = true;
|
|
||||||
} else {
|
|
||||||
s.message(message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
stop(message?: string) {
|
|
||||||
if (started) {
|
|
||||||
s.stop(message);
|
|
||||||
started = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
separator(): void {
|
|
||||||
p.log.info('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import type {
|
|
||||||
WizardPrompter,
|
|
||||||
SelectOption,
|
|
||||||
MultiSelectOption,
|
|
||||||
ProgressHandle,
|
|
||||||
} from './interface.js';
|
|
||||||
|
|
||||||
export type AnswerValue = string | boolean | string[];
|
|
||||||
|
|
||||||
export class HeadlessPrompter implements WizardPrompter {
|
|
||||||
private answers: Map<string, AnswerValue>;
|
|
||||||
private logs: string[] = [];
|
|
||||||
|
|
||||||
constructor(answers: Record<string, AnswerValue> = {}) {
|
|
||||||
this.answers = new Map(Object.entries(answers));
|
|
||||||
}
|
|
||||||
|
|
||||||
intro(message: string): void {
|
|
||||||
this.logs.push(`[intro] ${message}`);
|
|
||||||
}
|
|
||||||
outro(message: string): void {
|
|
||||||
this.logs.push(`[outro] ${message}`);
|
|
||||||
}
|
|
||||||
note(message: string, title?: string): void {
|
|
||||||
this.logs.push(`[note] ${title ?? ''}: ${message}`);
|
|
||||||
}
|
|
||||||
log(message: string): void {
|
|
||||||
this.logs.push(`[log] ${message}`);
|
|
||||||
}
|
|
||||||
warn(message: string): void {
|
|
||||||
this.logs.push(`[warn] ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async text(opts: {
|
|
||||||
message: string;
|
|
||||||
placeholder?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
validate?: (value: string) => string | void;
|
|
||||||
}): Promise<string> {
|
|
||||||
const answer = this.answers.get(opts.message);
|
|
||||||
const value =
|
|
||||||
typeof answer === 'string'
|
|
||||||
? answer
|
|
||||||
: opts.defaultValue !== undefined
|
|
||||||
? opts.defaultValue
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (value === undefined) {
|
|
||||||
throw new Error(`HeadlessPrompter: no answer for "${opts.message}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.validate) {
|
|
||||||
const error = opts.validate(value);
|
|
||||||
if (error)
|
|
||||||
throw new Error(`HeadlessPrompter validation failed for "${opts.message}": ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async confirm(opts: { message: string; initialValue?: boolean }): Promise<boolean> {
|
|
||||||
const answer = this.answers.get(opts.message);
|
|
||||||
if (typeof answer === 'boolean') return answer;
|
|
||||||
return opts.initialValue ?? true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async select<T>(opts: {
|
|
||||||
message: string;
|
|
||||||
options: SelectOption<T>[];
|
|
||||||
initialValue?: T;
|
|
||||||
}): Promise<T> {
|
|
||||||
const answer = this.answers.get(opts.message);
|
|
||||||
if (answer !== undefined) {
|
|
||||||
// Find matching option by value string comparison
|
|
||||||
const match = opts.options.find((o) => String(o.value) === String(answer));
|
|
||||||
if (match) return match.value;
|
|
||||||
}
|
|
||||||
if (opts.initialValue !== undefined) return opts.initialValue;
|
|
||||||
if (opts.options.length === 0) {
|
|
||||||
throw new Error(`HeadlessPrompter: no options for "${opts.message}"`);
|
|
||||||
}
|
|
||||||
const first = opts.options[0];
|
|
||||||
if (first === undefined) {
|
|
||||||
throw new Error(`HeadlessPrompter: no options for "${opts.message}"`);
|
|
||||||
}
|
|
||||||
return first.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async multiselect<T>(opts: {
|
|
||||||
message: string;
|
|
||||||
options: MultiSelectOption<T>[];
|
|
||||||
required?: boolean;
|
|
||||||
}): Promise<T[]> {
|
|
||||||
const answer = this.answers.get(opts.message);
|
|
||||||
if (Array.isArray(answer)) {
|
|
||||||
return opts.options
|
|
||||||
.filter((o) => (answer as string[]).includes(String(o.value)))
|
|
||||||
.map((o) => o.value);
|
|
||||||
}
|
|
||||||
return opts.options.filter((o) => o.selected).map((o) => o.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async groupMultiselect<T>(opts: {
|
|
||||||
message: string;
|
|
||||||
options: Record<string, MultiSelectOption<T>[]>;
|
|
||||||
required?: boolean;
|
|
||||||
}): Promise<T[]> {
|
|
||||||
const answer = this.answers.get(opts.message);
|
|
||||||
if (Array.isArray(answer)) {
|
|
||||||
const all = Object.values(opts.options).flat();
|
|
||||||
return all.filter((o) => (answer as string[]).includes(String(o.value))).map((o) => o.value);
|
|
||||||
}
|
|
||||||
return Object.values(opts.options)
|
|
||||||
.flat()
|
|
||||||
.filter((o) => o.selected)
|
|
||||||
.map((o) => o.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
spinner(): ProgressHandle {
|
|
||||||
return {
|
|
||||||
update(_message: string) {},
|
|
||||||
stop(_message?: string) {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
separator(): void {}
|
|
||||||
|
|
||||||
getLogs(): string[] {
|
|
||||||
return [...this.logs];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
export interface SelectOption<T = string> {
|
|
||||||
value: T;
|
|
||||||
label: string;
|
|
||||||
hint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MultiSelectOption<T = string> extends SelectOption<T> {
|
|
||||||
selected?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProgressHandle {
|
|
||||||
update(message: string): void;
|
|
||||||
stop(message?: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WizardPrompter {
|
|
||||||
intro(message: string): void;
|
|
||||||
outro(message: string): void;
|
|
||||||
note(message: string, title?: string): void;
|
|
||||||
log(message: string): void;
|
|
||||||
warn(message: string): void;
|
|
||||||
|
|
||||||
text(opts: {
|
|
||||||
message: string;
|
|
||||||
placeholder?: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
validate?: (value: string) => string | void;
|
|
||||||
}): Promise<string>;
|
|
||||||
|
|
||||||
confirm(opts: { message: string; initialValue?: boolean }): Promise<boolean>;
|
|
||||||
|
|
||||||
select<T>(opts: { message: string; options: SelectOption<T>[]; initialValue?: T }): Promise<T>;
|
|
||||||
|
|
||||||
multiselect<T>(opts: {
|
|
||||||
message: string;
|
|
||||||
options: MultiSelectOption<T>[];
|
|
||||||
required?: boolean;
|
|
||||||
}): Promise<T[]>;
|
|
||||||
|
|
||||||
groupMultiselect<T>(opts: {
|
|
||||||
message: string;
|
|
||||||
options: Record<string, MultiSelectOption<T>[]>;
|
|
||||||
required?: boolean;
|
|
||||||
}): Promise<T[]>;
|
|
||||||
|
|
||||||
spinner(): ProgressHandle;
|
|
||||||
|
|
||||||
separator(): void;
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { execSync } from 'node:child_process';
|
|
||||||
import { platform } from 'node:os';
|
|
||||||
import type { RuntimeName } from '../types.js';
|
|
||||||
|
|
||||||
export interface RuntimeInfo {
|
|
||||||
name: RuntimeName;
|
|
||||||
label: string;
|
|
||||||
installed: boolean;
|
|
||||||
path?: string;
|
|
||||||
version?: string;
|
|
||||||
installHint: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RUNTIME_DEFS: Record<
|
|
||||||
RuntimeName,
|
|
||||||
{ label: string; command: string; versionFlag: string; installHint: string }
|
|
||||||
> = {
|
|
||||||
claude: {
|
|
||||||
label: 'Claude Code',
|
|
||||||
command: 'claude',
|
|
||||||
versionFlag: '--version',
|
|
||||||
installHint: 'npm install -g @anthropic-ai/claude-code',
|
|
||||||
},
|
|
||||||
codex: {
|
|
||||||
label: 'Codex',
|
|
||||||
command: 'codex',
|
|
||||||
versionFlag: '--version',
|
|
||||||
installHint: 'npm install -g @openai/codex',
|
|
||||||
},
|
|
||||||
opencode: {
|
|
||||||
label: 'OpenCode',
|
|
||||||
command: 'opencode',
|
|
||||||
versionFlag: 'version',
|
|
||||||
installHint: 'See https://opencode.ai for install instructions',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function detectRuntime(name: RuntimeName): RuntimeInfo {
|
|
||||||
const def = RUNTIME_DEFS[name];
|
|
||||||
const isWindows = platform() === 'win32';
|
|
||||||
const whichCmd = isWindows ? `where ${def.command} 2>nul` : `which ${def.command} 2>/dev/null`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pathOutput =
|
|
||||||
execSync(whichCmd, {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: 5000,
|
|
||||||
})
|
|
||||||
.trim()
|
|
||||||
.split('\n')[0] ?? '';
|
|
||||||
|
|
||||||
let version: string | undefined;
|
|
||||||
try {
|
|
||||||
version = execSync(`${def.command} ${def.versionFlag} 2>/dev/null`, {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: 5000,
|
|
||||||
}).trim();
|
|
||||||
} catch {
|
|
||||||
// Version detection is optional
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
label: def.label,
|
|
||||||
installed: true,
|
|
||||||
path: pathOutput,
|
|
||||||
version,
|
|
||||||
installHint: def.installHint,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
label: def.label,
|
|
||||||
installed: false,
|
|
||||||
installHint: def.installHint,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getInstallInstructions(name: RuntimeName): string {
|
|
||||||
return RUNTIME_DEFS[name].installHint;
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { RuntimeName } from '../types.js';
|
|
||||||
import { getInstallInstructions } from './detector.js';
|
|
||||||
|
|
||||||
export function formatInstallInstructions(name: RuntimeName): string {
|
|
||||||
const hint = getInstallInstructions(name);
|
|
||||||
const labels: Record<RuntimeName, string> = {
|
|
||||||
claude: 'Claude Code',
|
|
||||||
codex: 'Codex',
|
|
||||||
opencode: 'OpenCode',
|
|
||||||
};
|
|
||||||
return `To install ${labels[name]}:\n ${hint}`;
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
||||||
import { join, dirname } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import type { RuntimeName } from '../types.js';
|
|
||||||
|
|
||||||
const MCP_ENTRY = {
|
|
||||||
command: 'npx',
|
|
||||||
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function configureMcpForRuntime(runtime: RuntimeName): void {
|
|
||||||
switch (runtime) {
|
|
||||||
case 'claude':
|
|
||||||
return configureClaudeMcp();
|
|
||||||
case 'codex':
|
|
||||||
return configureCodexMcp();
|
|
||||||
case 'opencode':
|
|
||||||
return configureOpenCodeMcp();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureDir(filePath: string): void {
|
|
||||||
mkdirSync(dirname(filePath), { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function configureClaudeMcp(): void {
|
|
||||||
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
||||||
ensureDir(settingsPath);
|
|
||||||
|
|
||||||
let data: Record<string, unknown> = {};
|
|
||||||
if (existsSync(settingsPath)) {
|
|
||||||
try {
|
|
||||||
data = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
// Start fresh if corrupt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!data['mcpServers'] ||
|
|
||||||
typeof data['mcpServers'] !== 'object' ||
|
|
||||||
Array.isArray(data['mcpServers'])
|
|
||||||
) {
|
|
||||||
data['mcpServers'] = {};
|
|
||||||
}
|
|
||||||
(data['mcpServers'] as Record<string, unknown>)['sequential-thinking'] = MCP_ENTRY;
|
|
||||||
|
|
||||||
writeFileSync(settingsPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
function configureCodexMcp(): void {
|
|
||||||
const configPath = join(homedir(), '.codex', 'config.toml');
|
|
||||||
ensureDir(configPath);
|
|
||||||
|
|
||||||
let content = '';
|
|
||||||
if (existsSync(configPath)) {
|
|
||||||
content = readFileSync(configPath, 'utf-8');
|
|
||||||
// Remove existing sequential-thinking section
|
|
||||||
content = content
|
|
||||||
.replace(/\[mcp_servers\.(sequential-thinking|sequential_thinking)\][\s\S]*?(?=\n\[|$)/g, '')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
content +=
|
|
||||||
'\n\n[mcp_servers.sequential-thinking]\n' +
|
|
||||||
'command = "npx"\n' +
|
|
||||||
'args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]\n';
|
|
||||||
|
|
||||||
writeFileSync(configPath, content, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
function configureOpenCodeMcp(): void {
|
|
||||||
const configPath = join(homedir(), '.config', 'opencode', 'config.json');
|
|
||||||
ensureDir(configPath);
|
|
||||||
|
|
||||||
let data: Record<string, unknown> = {};
|
|
||||||
if (existsSync(configPath)) {
|
|
||||||
try {
|
|
||||||
data = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
// Start fresh
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data['mcp'] || typeof data['mcp'] !== 'object' || Array.isArray(data['mcp'])) {
|
|
||||||
data['mcp'] = {};
|
|
||||||
}
|
|
||||||
(data['mcp'] as Record<string, unknown>)['sequential-thinking'] = {
|
|
||||||
type: 'local',
|
|
||||||
command: ['npx', '-y', '@modelcontextprotocol/server-sequential-thinking'],
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
writeFileSync(configPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { parse as parseYaml } from 'yaml';
|
|
||||||
import { RECOMMENDED_SKILLS } from '../constants.js';
|
|
||||||
|
|
||||||
export interface SkillEntry {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
version?: string;
|
|
||||||
recommended: boolean;
|
|
||||||
source: 'canonical' | 'local';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadSkillsCatalog(mosaicHome: string): SkillEntry[] {
|
|
||||||
const skills: SkillEntry[] = [];
|
|
||||||
|
|
||||||
// Load canonical skills
|
|
||||||
const canonicalDir = join(mosaicHome, 'skills');
|
|
||||||
if (existsSync(canonicalDir)) {
|
|
||||||
skills.push(...loadSkillsFromDir(canonicalDir, 'canonical'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to source repo
|
|
||||||
const sourceDir = join(mosaicHome, 'sources', 'agent-skills', 'skills');
|
|
||||||
if (skills.length === 0 && existsSync(sourceDir)) {
|
|
||||||
skills.push(...loadSkillsFromDir(sourceDir, 'canonical'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load local skills
|
|
||||||
const localDir = join(mosaicHome, 'skills-local');
|
|
||||||
if (existsSync(localDir)) {
|
|
||||||
skills.push(...loadSkillsFromDir(localDir, 'local'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSkillsFromDir(dir: string, source: 'canonical' | 'local'): SkillEntry[] {
|
|
||||||
const entries: SkillEntry[] = [];
|
|
||||||
|
|
||||||
let dirEntries;
|
|
||||||
try {
|
|
||||||
dirEntries = readdirSync(dir, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of dirEntries) {
|
|
||||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
||||||
|
|
||||||
const skillMdPath = join(dir, entry.name, 'SKILL.md');
|
|
||||||
if (!existsSync(skillMdPath)) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = readFileSync(skillMdPath, 'utf-8');
|
|
||||||
const frontmatter = parseFrontmatter(content);
|
|
||||||
|
|
||||||
entries.push({
|
|
||||||
name: (frontmatter['name'] as string | undefined) ?? entry.name,
|
|
||||||
description: (frontmatter['description'] as string | undefined) ?? '',
|
|
||||||
version: frontmatter['version'] as string | undefined,
|
|
||||||
recommended: RECOMMENDED_SKILLS.has(entry.name),
|
|
||||||
source,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Skip malformed skills
|
|
||||||
entries.push({
|
|
||||||
name: entry.name,
|
|
||||||
description: '',
|
|
||||||
recommended: RECOMMENDED_SKILLS.has(entry.name),
|
|
||||||
source,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFrontmatter(content: string): Record<string, unknown> {
|
|
||||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
||||||
if (!match?.[1]) return {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
return (parseYaml(match[1]) as Record<string, unknown>) ?? {};
|
|
||||||
} catch {
|
|
||||||
// Fallback: simple key-value parsing
|
|
||||||
const result: Record<string, string> = {};
|
|
||||||
for (const line of match[1].split('\n')) {
|
|
||||||
const kv = line.match(/^(\w[\w-]*)\s*:\s*(.+)/);
|
|
||||||
if (kv?.[1] !== undefined && kv[2] !== undefined) {
|
|
||||||
result[kv[1]] = kv[2].replace(/^['"]|['"]$/g, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
/**
|
|
||||||
* Skill category definitions and mapping.
|
|
||||||
* Skills are assigned to categories by name, with keyword fallback.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const SKILL_CATEGORIES: Record<string, string[]> = {
|
|
||||||
'Frontend & UI': [
|
|
||||||
'ai-sdk',
|
|
||||||
'algorithmic-art',
|
|
||||||
'antfu',
|
|
||||||
'canvas-design',
|
|
||||||
'frontend-design',
|
|
||||||
'next-best-practices',
|
|
||||||
'nuxt',
|
|
||||||
'pinia',
|
|
||||||
'shadcn-ui',
|
|
||||||
'slidev',
|
|
||||||
'tailwind-design-system',
|
|
||||||
'theme-factory',
|
|
||||||
'ui-animation',
|
|
||||||
'unocss',
|
|
||||||
'vercel-composition-patterns',
|
|
||||||
'vercel-react-best-practices',
|
|
||||||
'vercel-react-native-skills',
|
|
||||||
'vue',
|
|
||||||
'vue-best-practices',
|
|
||||||
'vue-router-best-practices',
|
|
||||||
'vueuse-functions',
|
|
||||||
'web-artifacts-builder',
|
|
||||||
'web-design-guidelines',
|
|
||||||
'vite',
|
|
||||||
'vitepress',
|
|
||||||
],
|
|
||||||
'Backend & Infrastructure': [
|
|
||||||
'architecture-patterns',
|
|
||||||
'fastapi',
|
|
||||||
'mcp-builder',
|
|
||||||
'nestjs-best-practices',
|
|
||||||
'python-performance-optimization',
|
|
||||||
'tsdown',
|
|
||||||
'turborepo',
|
|
||||||
'pnpm',
|
|
||||||
'dispatching-parallel-agents',
|
|
||||||
'subagent-driven-development',
|
|
||||||
'create-agent',
|
|
||||||
'proactive-agent',
|
|
||||||
'using-superpowers',
|
|
||||||
'kickstart',
|
|
||||||
'executing-plans',
|
|
||||||
],
|
|
||||||
'Testing & Quality': [
|
|
||||||
'code-review-excellence',
|
|
||||||
'lint',
|
|
||||||
'pr-reviewer',
|
|
||||||
'receiving-code-review',
|
|
||||||
'requesting-code-review',
|
|
||||||
'systematic-debugging',
|
|
||||||
'test-driven-development',
|
|
||||||
'verification-before-completion',
|
|
||||||
'vitest',
|
|
||||||
'vue-testing-best-practices',
|
|
||||||
'webapp-testing',
|
|
||||||
],
|
|
||||||
'Marketing & Growth': [
|
|
||||||
'ab-test-setup',
|
|
||||||
'analytics-tracking',
|
|
||||||
'competitor-alternatives',
|
|
||||||
'copy-editing',
|
|
||||||
'copywriting',
|
|
||||||
'email-sequence',
|
|
||||||
'form-cro',
|
|
||||||
'free-tool-strategy',
|
|
||||||
'launch-strategy',
|
|
||||||
'marketing-ideas',
|
|
||||||
'marketing-psychology',
|
|
||||||
'onboarding-cro',
|
|
||||||
'page-cro',
|
|
||||||
'paid-ads',
|
|
||||||
'paywall-upgrade-cro',
|
|
||||||
'popup-cro',
|
|
||||||
'pricing-strategy',
|
|
||||||
'product-marketing-context',
|
|
||||||
'programmatic-seo',
|
|
||||||
'referral-program',
|
|
||||||
'schema-markup',
|
|
||||||
'seo-audit',
|
|
||||||
'signup-flow-cro',
|
|
||||||
'social-content',
|
|
||||||
],
|
|
||||||
'Product & Strategy': [
|
|
||||||
'brainstorming',
|
|
||||||
'brand-guidelines',
|
|
||||||
'content-strategy',
|
|
||||||
'writing-plans',
|
|
||||||
'skill-creator',
|
|
||||||
'writing-skills',
|
|
||||||
'prd',
|
|
||||||
],
|
|
||||||
'Developer Practices': ['finishing-a-development-branch', 'using-git-worktrees'],
|
|
||||||
'Auth & Security': [
|
|
||||||
'better-auth-best-practices',
|
|
||||||
'create-auth-skill',
|
|
||||||
'email-and-password-best-practices',
|
|
||||||
'organization-best-practices',
|
|
||||||
'two-factor-authentication-best-practices',
|
|
||||||
],
|
|
||||||
'Content & Documentation': [
|
|
||||||
'doc-coauthoring',
|
|
||||||
'docx',
|
|
||||||
'internal-comms',
|
|
||||||
'pdf',
|
|
||||||
'pptx',
|
|
||||||
'slack-gif-creator',
|
|
||||||
'xlsx',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reverse lookup: skill name -> category
|
|
||||||
const SKILL_TO_CATEGORY = new Map<string, string>();
|
|
||||||
for (const [category, skills] of Object.entries(SKILL_CATEGORIES)) {
|
|
||||||
for (const skill of skills) {
|
|
||||||
SKILL_TO_CATEGORY.set(skill, category);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function categorizeSkill(name: string, description: string): string {
|
|
||||||
const mapped = SKILL_TO_CATEGORY.get(name);
|
|
||||||
if (mapped) return mapped;
|
|
||||||
return inferCategoryFromDescription(description);
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferCategoryFromDescription(desc: string): string {
|
|
||||||
const lower = desc.toLowerCase();
|
|
||||||
if (/\b(react|vue|css|frontend|ui|component|tailwind|design)\b/.test(lower))
|
|
||||||
return 'Frontend & UI';
|
|
||||||
if (/\b(api|backend|server|docker|infra|deploy)\b/.test(lower)) return 'Backend & Infrastructure';
|
|
||||||
if (/\b(test|lint|review|debug|quality)\b/.test(lower)) return 'Testing & Quality';
|
|
||||||
if (/\b(marketing|seo|copy|ads|cro|conversion|email)\b/.test(lower)) return 'Marketing & Growth';
|
|
||||||
if (/\b(auth|security|2fa|password|credential)\b/.test(lower)) return 'Auth & Security';
|
|
||||||
if (/\b(doc|pdf|word|sheet|writing|comms)\b/.test(lower)) return 'Content & Documentation';
|
|
||||||
if (/\b(product|strategy|brainstorm|plan|prd)\b/.test(lower)) return 'Product & Strategy';
|
|
||||||
return 'Developer Practices';
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { existsSync, readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import type { WizardPrompter } from '../prompter/interface.js';
|
|
||||||
import type { ConfigService } from '../config/config-service.js';
|
|
||||||
import type { WizardState, InstallAction } from '../types.js';
|
|
||||||
|
|
||||||
function detectExistingInstall(mosaicHome: string): boolean {
|
|
||||||
if (!existsSync(mosaicHome)) return false;
|
|
||||||
return (
|
|
||||||
existsSync(join(mosaicHome, 'bin/mosaic')) ||
|
|
||||||
existsSync(join(mosaicHome, 'AGENTS.md')) ||
|
|
||||||
existsSync(join(mosaicHome, 'SOUL.md'))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectExistingIdentity(mosaicHome: string): {
|
|
||||||
hasSoul: boolean;
|
|
||||||
hasUser: boolean;
|
|
||||||
hasTools: boolean;
|
|
||||||
agentName?: string;
|
|
||||||
} {
|
|
||||||
const soulPath = join(mosaicHome, 'SOUL.md');
|
|
||||||
const hasSoul = existsSync(soulPath);
|
|
||||||
let agentName: string | undefined;
|
|
||||||
|
|
||||||
if (hasSoul) {
|
|
||||||
try {
|
|
||||||
const content = readFileSync(soulPath, 'utf-8');
|
|
||||||
const match = content.match(/You are \*\*(.+?)\*\*/);
|
|
||||||
agentName = match?.[1];
|
|
||||||
} catch {
|
|
||||||
// Non-fatal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasSoul,
|
|
||||||
hasUser: existsSync(join(mosaicHome, 'USER.md')),
|
|
||||||
hasTools: existsSync(join(mosaicHome, 'TOOLS.md')),
|
|
||||||
agentName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function detectInstallStage(
|
|
||||||
p: WizardPrompter,
|
|
||||||
state: WizardState,
|
|
||||||
config: ConfigService,
|
|
||||||
): Promise<void> {
|
|
||||||
const existing = detectExistingInstall(state.mosaicHome);
|
|
||||||
if (!existing) {
|
|
||||||
state.installAction = 'fresh';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const identity = detectExistingIdentity(state.mosaicHome);
|
|
||||||
const identitySummary = identity.agentName
|
|
||||||
? `Agent: ${identity.agentName}`
|
|
||||||
: 'No identity configured';
|
|
||||||
|
|
||||||
p.note(
|
|
||||||
`Found existing Mosaic installation at:\n${state.mosaicHome}\n\n` +
|
|
||||||
`${identitySummary}\n` +
|
|
||||||
`SOUL.md: ${identity.hasSoul ? 'yes' : 'no'}\n` +
|
|
||||||
`USER.md: ${identity.hasUser ? 'yes' : 'no'}\n` +
|
|
||||||
`TOOLS.md: ${identity.hasTools ? 'yes' : 'no'}`,
|
|
||||||
'Existing Installation Detected',
|
|
||||||
);
|
|
||||||
|
|
||||||
state.installAction = await p.select<InstallAction>({
|
|
||||||
message: 'What would you like to do?',
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
value: 'keep',
|
|
||||||
label: 'Keep identity, update framework',
|
|
||||||
hint: 'Preserves SOUL.md, USER.md, TOOLS.md, memory/',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'reconfigure',
|
|
||||||
label: 'Reconfigure identity',
|
|
||||||
hint: 'Re-run identity setup, update framework',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'reset',
|
|
||||||
label: 'Fresh install',
|
|
||||||
hint: 'Replace everything',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (state.installAction === 'keep') {
|
|
||||||
state.soul = await config.readSoul();
|
|
||||||
state.user = await config.readUser();
|
|
||||||
state.tools = await config.readTools();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import { existsSync, readFileSync, appendFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { platform } from 'node:os';
|
|
||||||
import type { WizardPrompter } from '../prompter/interface.js';
|
|
||||||
import type { ConfigService } from '../config/config-service.js';
|
|
||||||
import type { WizardState } from '../types.js';
|
|
||||||
import { getShellProfilePath } from '../platform/detect.js';
|
|
||||||
|
|
||||||
function linkRuntimeAssets(mosaicHome: string): void {
|
|
||||||
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
|
||||||
if (existsSync(script)) {
|
|
||||||
try {
|
|
||||||
spawnSync('bash', [script], { timeout: 30000, stdio: 'pipe' });
|
|
||||||
} catch {
|
|
||||||
// Non-fatal: wizard continues
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncSkills(mosaicHome: string): void {
|
|
||||||
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
|
|
||||||
if (existsSync(script)) {
|
|
||||||
try {
|
|
||||||
spawnSync('bash', [script], { timeout: 60000, stdio: 'pipe' });
|
|
||||||
} catch {
|
|
||||||
// Non-fatal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DoctorResult {
|
|
||||||
warnings: number;
|
|
||||||
output: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runDoctor(mosaicHome: string): DoctorResult {
|
|
||||||
const script = join(mosaicHome, 'bin', 'mosaic-doctor');
|
|
||||||
if (!existsSync(script)) {
|
|
||||||
return { warnings: 0, output: 'mosaic-doctor not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = spawnSync('bash', [script], {
|
|
||||||
timeout: 30000,
|
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: 'pipe',
|
|
||||||
});
|
|
||||||
const output = result.stdout ?? '';
|
|
||||||
const warnings = (output.match(/WARN/g) ?? []).length;
|
|
||||||
return { warnings, output };
|
|
||||||
} catch {
|
|
||||||
return { warnings: 1, output: 'Doctor check failed' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type PathAction = 'already' | 'added' | 'skipped';
|
|
||||||
|
|
||||||
function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction {
|
|
||||||
const binDir = join(mosaicHome, 'bin');
|
|
||||||
const currentPath = process.env['PATH'] ?? '';
|
|
||||||
|
|
||||||
if (currentPath.includes(binDir)) {
|
|
||||||
return 'already';
|
|
||||||
}
|
|
||||||
|
|
||||||
const profilePath = getShellProfilePath();
|
|
||||||
if (!profilePath) return 'skipped';
|
|
||||||
|
|
||||||
const isWindows = platform() === 'win32';
|
|
||||||
const exportLine = isWindows
|
|
||||||
? `\n# Mosaic\n$env:Path = "${binDir};$env:Path"\n`
|
|
||||||
: `\n# Mosaic\nexport PATH="${binDir}:$PATH"\n`;
|
|
||||||
|
|
||||||
// Check if already in profile
|
|
||||||
if (existsSync(profilePath)) {
|
|
||||||
const content = readFileSync(profilePath, 'utf-8');
|
|
||||||
if (content.includes(binDir)) {
|
|
||||||
return 'already';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
appendFileSync(profilePath, exportLine, 'utf-8');
|
|
||||||
return 'added';
|
|
||||||
} catch {
|
|
||||||
return 'skipped';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function finalizeStage(
|
|
||||||
p: WizardPrompter,
|
|
||||||
state: WizardState,
|
|
||||||
config: ConfigService,
|
|
||||||
): Promise<void> {
|
|
||||||
p.separator();
|
|
||||||
|
|
||||||
const spin = p.spinner();
|
|
||||||
|
|
||||||
// 1. Sync framework files (before config writes so identity files aren't overwritten)
|
|
||||||
spin.update('Syncing framework files...');
|
|
||||||
await config.syncFramework(state.installAction);
|
|
||||||
|
|
||||||
// 2. Write config files (after sync so they aren't overwritten by source templates)
|
|
||||||
if (state.installAction !== 'keep') {
|
|
||||||
spin.update('Writing configuration files...');
|
|
||||||
await config.writeSoul(state.soul);
|
|
||||||
await config.writeUser(state.user);
|
|
||||||
await config.writeTools(state.tools);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Link runtime assets
|
|
||||||
spin.update('Linking runtime assets...');
|
|
||||||
linkRuntimeAssets(state.mosaicHome);
|
|
||||||
|
|
||||||
// 4. Sync skills
|
|
||||||
if (state.selectedSkills.length > 0) {
|
|
||||||
spin.update('Syncing skills...');
|
|
||||||
syncSkills(state.mosaicHome);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Run doctor
|
|
||||||
spin.update('Running health audit...');
|
|
||||||
const doctorResult = runDoctor(state.mosaicHome);
|
|
||||||
|
|
||||||
spin.stop('Installation complete');
|
|
||||||
|
|
||||||
// 6. PATH setup
|
|
||||||
const pathAction = setupPath(state.mosaicHome, p);
|
|
||||||
|
|
||||||
// 7. Summary
|
|
||||||
const summary: string[] = [
|
|
||||||
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
|
|
||||||
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
|
|
||||||
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
|
|
||||||
`Skills: ${state.selectedSkills.length.toString()} selected`,
|
|
||||||
`Config: ${state.mosaicHome}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (doctorResult.warnings > 0) {
|
|
||||||
summary.push(
|
|
||||||
`Health: ${doctorResult.warnings.toString()} warning(s) — run 'mosaic doctor' for details`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
summary.push('Health: all checks passed');
|
|
||||||
}
|
|
||||||
|
|
||||||
p.note(summary.join('\n'), 'Installation Summary');
|
|
||||||
|
|
||||||
// 8. Next steps
|
|
||||||
const nextSteps: string[] = [];
|
|
||||||
if (pathAction === 'added') {
|
|
||||||
const profilePath = getShellProfilePath();
|
|
||||||
nextSteps.push(`Reload shell: source ${profilePath ?? '~/.profile'}`);
|
|
||||||
}
|
|
||||||
if (state.runtimes.detected.length === 0) {
|
|
||||||
nextSteps.push('Install at least one runtime (claude, codex, or opencode)');
|
|
||||||
}
|
|
||||||
nextSteps.push("Launch with 'mosaic claude' (or codex/opencode)");
|
|
||||||
nextSteps.push('Edit identity files directly in ~/.config/mosaic/ for fine-tuning');
|
|
||||||
|
|
||||||
p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps');
|
|
||||||
|
|
||||||
p.outro('Mosaic is ready.');
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { WizardPrompter } from '../prompter/interface.js';
|
|
||||||
import type { WizardState, WizardMode } from '../types.js';
|
|
||||||
|
|
||||||
export async function modeSelectStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
|
||||||
state.mode = await p.select<WizardMode>({
|
|
||||||
message: 'Installation mode',
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
value: 'quick',
|
|
||||||
label: 'Quick Start',
|
|
||||||
hint: 'Sensible defaults, minimal questions (~2 min)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'advanced',
|
|
||||||
label: 'Advanced',
|
|
||||||
hint: 'Full customization of identity, runtimes, and skills',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import type { WizardPrompter } from '../prompter/interface.js';
|
|
||||||
import type { WizardState, RuntimeName } from '../types.js';
|
|
||||||
import { detectRuntime, type RuntimeInfo } from '../runtime/detector.js';
|
|
||||||
import { formatInstallInstructions } from '../runtime/installer.js';
|
|
||||||
import { configureMcpForRuntime } from '../runtime/mcp-config.js';
|
|
||||||
|
|
||||||
const RUNTIME_NAMES: RuntimeName[] = ['claude', 'codex', 'opencode'];
|
|
||||||
|
|
||||||
export async function runtimeSetupStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
|
||||||
p.separator();
|
|
||||||
|
|
||||||
const spin = p.spinner();
|
|
||||||
spin.update('Detecting installed runtimes...');
|
|
||||||
|
|
||||||
const runtimes: RuntimeInfo[] = RUNTIME_NAMES.map(detectRuntime);
|
|
||||||
|
|
||||||
spin.stop('Runtime detection complete');
|
|
||||||
|
|
||||||
const detected = runtimes.filter((r) => r.installed);
|
|
||||||
const notDetected = runtimes.filter((r) => !r.installed);
|
|
||||||
|
|
||||||
if (detected.length > 0) {
|
|
||||||
const summary = detected
|
|
||||||
.map((r) => ` ${r.label}: ${r.version ?? 'installed'} (${r.path ?? 'unknown'})`)
|
|
||||||
.join('\n');
|
|
||||||
p.note(summary, 'Detected Runtimes');
|
|
||||||
} else {
|
|
||||||
p.warn('No runtimes detected. Install at least one to use Mosaic.');
|
|
||||||
}
|
|
||||||
|
|
||||||
state.runtimes.detected = detected.map((r) => r.name);
|
|
||||||
|
|
||||||
// Offer installation info for missing runtimes in advanced mode
|
|
||||||
if (state.mode === 'advanced' && notDetected.length > 0) {
|
|
||||||
const showInstall = await p.confirm({
|
|
||||||
message: `${notDetected.length.toString()} runtime(s) not found. Show install instructions?`,
|
|
||||||
initialValue: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (showInstall) {
|
|
||||||
for (const rt of notDetected) {
|
|
||||||
p.note(formatInstallInstructions(rt.name), `Install ${rt.label}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure MCP sequential-thinking for detected runtimes
|
|
||||||
if (detected.length > 0) {
|
|
||||||
const spin2 = p.spinner();
|
|
||||||
spin2.update('Configuring sequential-thinking MCP...');
|
|
||||||
try {
|
|
||||||
for (const rt of detected) {
|
|
||||||
configureMcpForRuntime(rt.name);
|
|
||||||
}
|
|
||||||
spin2.stop('MCP sequential-thinking configured');
|
|
||||||
state.runtimes.mcpConfigured = true;
|
|
||||||
} catch (err) {
|
|
||||||
spin2.stop('MCP configuration failed (non-fatal)');
|
|
||||||
p.warn(
|
|
||||||
`MCP setup failed: ${err instanceof Error ? err.message : String(err)}. Run 'mosaic seq fix' later.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import type { WizardPrompter } from '../prompter/interface.js';
|
|
||||||
import type { WizardState } from '../types.js';
|
|
||||||
import { loadSkillsCatalog } from '../skills/catalog.js';
|
|
||||||
import { SKILL_CATEGORIES, categorizeSkill } from '../skills/categories.js';
|
|
||||||
|
|
||||||
function truncate(str: string, max: number): string {
|
|
||||||
if (str.length <= max) return str;
|
|
||||||
return str.slice(0, max - 1) + '\u2026';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function skillsSelectStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
|
||||||
p.separator();
|
|
||||||
|
|
||||||
const spin = p.spinner();
|
|
||||||
spin.update('Loading skills catalog...');
|
|
||||||
|
|
||||||
const catalog = loadSkillsCatalog(state.mosaicHome);
|
|
||||||
spin.stop(`Found ${catalog.length.toString()} available skills`);
|
|
||||||
|
|
||||||
if (catalog.length === 0) {
|
|
||||||
p.warn("No skills found. Run 'mosaic sync' after installation to fetch skills.");
|
|
||||||
state.selectedSkills = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.mode === 'quick') {
|
|
||||||
const defaults = catalog.filter((s) => s.recommended).map((s) => s.name);
|
|
||||||
state.selectedSkills = defaults;
|
|
||||||
p.note(
|
|
||||||
`Selected ${defaults.length.toString()} recommended skills.\n` +
|
|
||||||
`Run 'mosaic sync' later to browse the full catalog.`,
|
|
||||||
'Skills',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Advanced mode: categorized browsing
|
|
||||||
p.note(
|
|
||||||
'Skills give agents domain expertise for specific tasks.\n' +
|
|
||||||
'Browse by category and select the ones you want.\n' +
|
|
||||||
"You can always change this later with 'mosaic sync'.",
|
|
||||||
'Skills Selection',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build grouped options
|
|
||||||
const grouped: Record<
|
|
||||||
string,
|
|
||||||
{ value: string; label: string; hint?: string; selected?: boolean }[]
|
|
||||||
> = {};
|
|
||||||
|
|
||||||
// Initialize all categories
|
|
||||||
for (const categoryName of Object.keys(SKILL_CATEGORIES)) {
|
|
||||||
grouped[categoryName] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const skill of catalog) {
|
|
||||||
const category = categorizeSkill(skill.name, skill.description);
|
|
||||||
if (!grouped[category]) grouped[category] = [];
|
|
||||||
grouped[category]!.push({
|
|
||||||
value: skill.name,
|
|
||||||
label: skill.name,
|
|
||||||
hint: truncate(skill.description, 60),
|
|
||||||
selected: skill.recommended,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove empty categories
|
|
||||||
for (const key of Object.keys(grouped)) {
|
|
||||||
if ((grouped[key]?.length ?? 0) === 0) delete grouped[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
state.selectedSkills = await p.groupMultiselect({
|
|
||||||
message: 'Select skills (space to toggle)',
|
|
||||||
options: grouped,
|
|
||||||
required: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import type { WizardPrompter } from '../prompter/interface.js';
|
|
||||||
import type { WizardState, CommunicationStyle } from '../types.js';
|
|
||||||
import { DEFAULTS } from '../constants.js';
|
|
||||||
|
|
||||||
export async function soulSetupStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
|
||||||
if (state.installAction === 'keep') return;
|
|
||||||
|
|
||||||
p.separator();
|
|
||||||
p.note(
|
|
||||||
'Your agent identity defines how AI assistants behave,\n' +
|
|
||||||
'their principles, and communication style.\n' +
|
|
||||||
'This creates SOUL.md.',
|
|
||||||
'Agent Identity',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!state.soul.agentName) {
|
|
||||||
state.soul.agentName = await p.text({
|
|
||||||
message: 'What name should agents use?',
|
|
||||||
placeholder: 'e.g., Jarvis, Assistant, Mosaic',
|
|
||||||
defaultValue: DEFAULTS.agentName,
|
|
||||||
validate: (v) => {
|
|
||||||
if (v.length === 0) return 'Name cannot be empty';
|
|
||||||
if (v.length > 50) return 'Name must be under 50 characters';
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.mode === 'advanced') {
|
|
||||||
if (!state.soul.roleDescription) {
|
|
||||||
state.soul.roleDescription = await p.text({
|
|
||||||
message: 'Agent role description',
|
|
||||||
placeholder: 'e.g., execution partner and visibility engine',
|
|
||||||
defaultValue: DEFAULTS.roleDescription,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
state.soul.roleDescription ??= DEFAULTS.roleDescription;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.soul.communicationStyle) {
|
|
||||||
state.soul.communicationStyle = await p.select<CommunicationStyle>({
|
|
||||||
message: 'Communication style',
|
|
||||||
options: [
|
|
||||||
{ value: 'direct', label: 'Direct', hint: 'Concise, no fluff, actionable' },
|
|
||||||
{ value: 'friendly', label: 'Friendly', hint: 'Warm but efficient, conversational' },
|
|
||||||
{ value: 'formal', label: 'Formal', hint: 'Professional, structured, thorough' },
|
|
||||||
],
|
|
||||||
initialValue: 'direct',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.mode === 'advanced') {
|
|
||||||
if (!state.soul.accessibility) {
|
|
||||||
state.soul.accessibility = await p.text({
|
|
||||||
message: 'Accessibility preferences',
|
|
||||||
placeholder: "e.g., ADHD-friendly chunking, dyslexia-aware formatting, or 'none'",
|
|
||||||
defaultValue: 'none',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.soul.customGuardrails) {
|
|
||||||
state.soul.customGuardrails = await p.text({
|
|
||||||
message: 'Custom guardrails (optional)',
|
|
||||||
placeholder: 'e.g., Never auto-commit to main',
|
|
||||||
defaultValue: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import type { WizardPrompter } from '../prompter/interface.js';
|
|
||||||
import type { WizardState, GitProvider } from '../types.js';
|
|
||||||
import { DEFAULTS } from '../constants.js';
|
|
||||||
|
|
||||||
export async function toolsSetupStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
|
||||||
if (state.installAction === 'keep') return;
|
|
||||||
|
|
||||||
if (state.mode === 'quick') {
|
|
||||||
state.tools.gitProviders = [];
|
|
||||||
state.tools.credentialsLocation = DEFAULTS.credentialsLocation;
|
|
||||||
state.tools.customToolsSection = DEFAULTS.customToolsSection;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.separator();
|
|
||||||
p.note(
|
|
||||||
'Tool configuration tells agents about your git providers,\n' +
|
|
||||||
'credential locations, and custom tools.\n' +
|
|
||||||
'This creates TOOLS.md.',
|
|
||||||
'Tool Reference',
|
|
||||||
);
|
|
||||||
|
|
||||||
const addProviders = await p.confirm({
|
|
||||||
message: 'Configure git providers?',
|
|
||||||
initialValue: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
state.tools.gitProviders = [];
|
|
||||||
if (addProviders) {
|
|
||||||
let addMore = true;
|
|
||||||
while (addMore) {
|
|
||||||
const name = await p.text({
|
|
||||||
message: 'Provider name',
|
|
||||||
placeholder: 'e.g., Gitea, GitHub',
|
|
||||||
});
|
|
||||||
const url = await p.text({
|
|
||||||
message: 'Provider URL',
|
|
||||||
placeholder: 'e.g., https://github.com',
|
|
||||||
});
|
|
||||||
const cli = await p.select<string>({
|
|
||||||
message: 'CLI tool',
|
|
||||||
options: [
|
|
||||||
{ value: 'gh', label: 'gh (GitHub CLI)' },
|
|
||||||
{ value: 'tea', label: 'tea (Gitea CLI)' },
|
|
||||||
{ value: 'glab', label: 'glab (GitLab CLI)' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const purpose = await p.text({
|
|
||||||
message: 'Purpose',
|
|
||||||
placeholder: 'e.g., Primary code hosting',
|
|
||||||
defaultValue: 'Code hosting',
|
|
||||||
});
|
|
||||||
|
|
||||||
state.tools.gitProviders.push({
|
|
||||||
name,
|
|
||||||
url,
|
|
||||||
cli,
|
|
||||||
purpose,
|
|
||||||
} satisfies GitProvider);
|
|
||||||
|
|
||||||
addMore = await p.confirm({
|
|
||||||
message: 'Add another provider?',
|
|
||||||
initialValue: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
state.tools.credentialsLocation = await p.text({
|
|
||||||
message: 'Credential file path',
|
|
||||||
placeholder: "e.g., ~/.secrets/credentials.env, or 'none'",
|
|
||||||
defaultValue: DEFAULTS.credentialsLocation,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import type { WizardPrompter } from '../prompter/interface.js';
|
|
||||||
import type { WizardState } from '../types.js';
|
|
||||||
import { DEFAULTS } from '../constants.js';
|
|
||||||
import { buildCommunicationPrefs } from '../template/builders.js';
|
|
||||||
|
|
||||||
export async function userSetupStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
|
||||||
if (state.installAction === 'keep') return;
|
|
||||||
|
|
||||||
p.separator();
|
|
||||||
p.note(
|
|
||||||
'Your user profile helps agents understand your context,\n' +
|
|
||||||
'accessibility needs, and communication preferences.\n' +
|
|
||||||
'This creates USER.md.',
|
|
||||||
'User Profile',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!state.user.userName) {
|
|
||||||
state.user.userName = await p.text({
|
|
||||||
message: 'Your name',
|
|
||||||
placeholder: 'How agents should address you',
|
|
||||||
defaultValue: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.user.pronouns) {
|
|
||||||
state.user.pronouns = await p.text({
|
|
||||||
message: 'Your pronouns',
|
|
||||||
placeholder: 'e.g., He/Him, She/Her, They/Them',
|
|
||||||
defaultValue: DEFAULTS.pronouns,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-detect timezone
|
|
||||||
let detectedTz: string;
|
|
||||||
try {
|
|
||||||
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
||||||
} catch {
|
|
||||||
detectedTz = DEFAULTS.timezone;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.user.timezone) {
|
|
||||||
state.user.timezone = await p.text({
|
|
||||||
message: 'Your timezone',
|
|
||||||
placeholder: `e.g., ${detectedTz}`,
|
|
||||||
defaultValue: detectedTz,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.mode === 'advanced') {
|
|
||||||
state.user.background = await p.text({
|
|
||||||
message: 'Professional background (brief)',
|
|
||||||
placeholder: 'e.g., Full-stack developer, 10 years TypeScript/React',
|
|
||||||
defaultValue: DEFAULTS.background,
|
|
||||||
});
|
|
||||||
|
|
||||||
state.user.accessibilitySection = await p.text({
|
|
||||||
message: 'Neurodivergence / accessibility accommodations',
|
|
||||||
placeholder: 'e.g., ADHD-friendly chunking, or press Enter to skip',
|
|
||||||
defaultValue: DEFAULTS.accessibilitySection,
|
|
||||||
});
|
|
||||||
|
|
||||||
state.user.personalBoundaries = await p.text({
|
|
||||||
message: 'Personal boundaries for agents',
|
|
||||||
placeholder: 'e.g., No unsolicited career advice, or press Enter to skip',
|
|
||||||
defaultValue: DEFAULTS.personalBoundaries,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
state.user.background = DEFAULTS.background;
|
|
||||||
state.user.accessibilitySection = DEFAULTS.accessibilitySection;
|
|
||||||
state.user.personalBoundaries = DEFAULTS.personalBoundaries;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive communication preferences from SOUL style
|
|
||||||
state.user.communicationPrefs = buildCommunicationPrefs(
|
|
||||||
state.soul.communicationStyle ?? 'direct',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { WizardPrompter } from '../prompter/interface.js';
|
|
||||||
import type { WizardState } from '../types.js';
|
|
||||||
import { VERSION } from '../constants.js';
|
|
||||||
|
|
||||||
export async function welcomeStage(p: WizardPrompter, _state: WizardState): Promise<void> {
|
|
||||||
p.intro(`Mosaic Installation Wizard v${VERSION}`);
|
|
||||||
p.note(
|
|
||||||
`Mosaic is an agent framework that gives AI coding assistants\n` +
|
|
||||||
`a persistent identity, shared skills, and structured workflows.\n\n` +
|
|
||||||
`It works with Claude Code, Codex, and OpenCode.\n\n` +
|
|
||||||
`All config is stored locally in ~/.config/mosaic/.\n` +
|
|
||||||
`No data is sent anywhere. No accounts required.`,
|
|
||||||
'What is Mosaic?',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import type {
|
|
||||||
CommunicationStyle,
|
|
||||||
SoulConfig,
|
|
||||||
UserConfig,
|
|
||||||
ToolsConfig,
|
|
||||||
GitProvider,
|
|
||||||
} from '../types.js';
|
|
||||||
import { DEFAULTS } from '../constants.js';
|
|
||||||
import type { TemplateVars } from './engine.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build behavioral principles text based on communication style.
|
|
||||||
* Replicates mosaic-init lines 177-204 exactly.
|
|
||||||
*/
|
|
||||||
function buildBehavioralPrinciples(style: CommunicationStyle, accessibility?: string): string {
|
|
||||||
let principles: string;
|
|
||||||
|
|
||||||
switch (style) {
|
|
||||||
case 'direct':
|
|
||||||
principles = `1. Clarity over performance theater.
|
|
||||||
2. Practical execution over abstract planning.
|
|
||||||
3. Truthfulness over confidence: state uncertainty explicitly.
|
|
||||||
4. Visible state over hidden assumptions.
|
|
||||||
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations.`;
|
|
||||||
break;
|
|
||||||
case 'friendly':
|
|
||||||
principles = `1. Be helpful and approachable while staying efficient.
|
|
||||||
2. Provide context and explain reasoning when helpful.
|
|
||||||
3. Truthfulness over confidence: state uncertainty explicitly.
|
|
||||||
4. Visible state over hidden assumptions.
|
|
||||||
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations.`;
|
|
||||||
break;
|
|
||||||
case 'formal':
|
|
||||||
principles = `1. Maintain professional, structured communication.
|
|
||||||
2. Provide thorough analysis with explicit tradeoffs.
|
|
||||||
3. Truthfulness over confidence: state uncertainty explicitly.
|
|
||||||
4. Document decisions and rationale clearly.
|
|
||||||
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations.`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accessibility && accessibility !== 'none' && accessibility.length > 0) {
|
|
||||||
principles += `\n6. ${accessibility}.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return principles;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build communication style text based on style choice.
|
|
||||||
* Replicates mosaic-init lines 208-227 exactly.
|
|
||||||
*/
|
|
||||||
function buildCommunicationStyleText(style: CommunicationStyle): string {
|
|
||||||
switch (style) {
|
|
||||||
case 'direct':
|
|
||||||
return `- Be direct, concise, and concrete.
|
|
||||||
- Avoid fluff, hype, and anthropomorphic roleplay.
|
|
||||||
- Do not simulate certainty when facts are missing.
|
|
||||||
- Prefer actionable next steps and explicit tradeoffs.`;
|
|
||||||
case 'friendly':
|
|
||||||
return `- Be warm and conversational while staying focused.
|
|
||||||
- Explain your reasoning when it helps the user.
|
|
||||||
- Do not simulate certainty when facts are missing.
|
|
||||||
- Prefer actionable next steps with clear context.`;
|
|
||||||
case 'formal':
|
|
||||||
return `- Use professional, structured language.
|
|
||||||
- Provide thorough explanations with supporting detail.
|
|
||||||
- Do not simulate certainty when facts are missing.
|
|
||||||
- Present options with explicit tradeoffs and recommendations.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build communication preferences for USER.md based on style.
|
|
||||||
* Replicates mosaic-init lines 299-316 exactly.
|
|
||||||
*/
|
|
||||||
export function buildCommunicationPrefs(style: CommunicationStyle): string {
|
|
||||||
switch (style) {
|
|
||||||
case 'direct':
|
|
||||||
return `- Direct and concise
|
|
||||||
- No sycophancy
|
|
||||||
- Executive summaries and tables for overview`;
|
|
||||||
case 'friendly':
|
|
||||||
return `- Warm and conversational
|
|
||||||
- Explain reasoning when helpful
|
|
||||||
- Balance thoroughness with brevity`;
|
|
||||||
case 'formal':
|
|
||||||
return `- Professional and structured
|
|
||||||
- Thorough explanations with supporting detail
|
|
||||||
- Formal tone with explicit recommendations`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build git providers markdown table from provider list.
|
|
||||||
* Replicates mosaic-init lines 362-384.
|
|
||||||
*/
|
|
||||||
function buildGitProvidersTable(providers?: GitProvider[]): string {
|
|
||||||
if (!providers || providers.length === 0) {
|
|
||||||
return DEFAULTS.gitProvidersTable;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = providers
|
|
||||||
.map((p) => `| ${p.name} | ${p.url} | \`${p.cli}\` | ${p.purpose} |`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
return `| Instance | URL | CLI | Purpose |
|
|
||||||
|----------|-----|-----|---------|
|
|
||||||
${rows}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSoulTemplateVars(config: SoulConfig): TemplateVars {
|
|
||||||
const style = config.communicationStyle ?? 'direct';
|
|
||||||
const guardrails = config.customGuardrails ? `- ${config.customGuardrails}` : '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
AGENT_NAME: config.agentName ?? DEFAULTS.agentName,
|
|
||||||
ROLE_DESCRIPTION: config.roleDescription ?? DEFAULTS.roleDescription,
|
|
||||||
BEHAVIORAL_PRINCIPLES: buildBehavioralPrinciples(style, config.accessibility),
|
|
||||||
COMMUNICATION_STYLE: buildCommunicationStyleText(style),
|
|
||||||
CUSTOM_GUARDRAILS: guardrails,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildUserTemplateVars(config: UserConfig): TemplateVars {
|
|
||||||
return {
|
|
||||||
USER_NAME: config.userName ?? '',
|
|
||||||
PRONOUNS: config.pronouns ?? DEFAULTS.pronouns,
|
|
||||||
TIMEZONE: config.timezone ?? DEFAULTS.timezone,
|
|
||||||
BACKGROUND: config.background ?? DEFAULTS.background,
|
|
||||||
ACCESSIBILITY_SECTION: config.accessibilitySection ?? DEFAULTS.accessibilitySection,
|
|
||||||
COMMUNICATION_PREFS: config.communicationPrefs ?? buildCommunicationPrefs('direct'),
|
|
||||||
PERSONAL_BOUNDARIES: config.personalBoundaries ?? DEFAULTS.personalBoundaries,
|
|
||||||
PROJECTS_TABLE: config.projectsTable ?? DEFAULTS.projectsTable,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildToolsTemplateVars(config: ToolsConfig): TemplateVars {
|
|
||||||
return {
|
|
||||||
GIT_PROVIDERS_TABLE: buildGitProvidersTable(config.gitProviders),
|
|
||||||
CREDENTIALS_LOCATION: config.credentialsLocation ?? DEFAULTS.credentialsLocation,
|
|
||||||
CUSTOM_TOOLS_SECTION: config.customToolsSection ?? DEFAULTS.customToolsSection,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
export interface TemplateVars {
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces {{PLACEHOLDER}} tokens with provided values.
|
|
||||||
* Does NOT expand ${ENV_VAR} syntax — those pass through for shell resolution.
|
|
||||||
*/
|
|
||||||
export function renderTemplate(
|
|
||||||
template: string,
|
|
||||||
vars: TemplateVars,
|
|
||||||
options: { strict?: boolean } = {},
|
|
||||||
): string {
|
|
||||||
return template.replace(/\{\{([A-Z_][A-Z0-9_]*)\}\}/g, (match, varName: string) => {
|
|
||||||
if (varName in vars) {
|
|
||||||
return vars[varName] ?? '';
|
|
||||||
}
|
|
||||||
if (options.strict) {
|
|
||||||
throw new Error(`Template variable not provided: {{${varName}}}`);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
export type WizardMode = 'quick' | 'advanced';
|
|
||||||
export type InstallAction = 'fresh' | 'keep' | 'reconfigure' | 'reset';
|
|
||||||
export type CommunicationStyle = 'direct' | 'friendly' | 'formal';
|
|
||||||
export type RuntimeName = 'claude' | 'codex' | 'opencode';
|
|
||||||
|
|
||||||
export interface SoulConfig {
|
|
||||||
agentName?: string;
|
|
||||||
roleDescription?: string;
|
|
||||||
communicationStyle?: CommunicationStyle;
|
|
||||||
accessibility?: string;
|
|
||||||
customGuardrails?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserConfig {
|
|
||||||
userName?: string;
|
|
||||||
pronouns?: string;
|
|
||||||
timezone?: string;
|
|
||||||
background?: string;
|
|
||||||
accessibilitySection?: string;
|
|
||||||
communicationPrefs?: string;
|
|
||||||
personalBoundaries?: string;
|
|
||||||
projectsTable?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GitProvider {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
cli: string;
|
|
||||||
purpose: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolsConfig {
|
|
||||||
gitProviders?: GitProvider[];
|
|
||||||
credentialsLocation?: string;
|
|
||||||
customToolsSection?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeState {
|
|
||||||
detected: RuntimeName[];
|
|
||||||
mcpConfigured: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WizardState {
|
|
||||||
mosaicHome: string;
|
|
||||||
sourceDir: string;
|
|
||||||
mode: WizardMode;
|
|
||||||
installAction: InstallAction;
|
|
||||||
soul: SoulConfig;
|
|
||||||
user: UserConfig;
|
|
||||||
tools: ToolsConfig;
|
|
||||||
runtimes: RuntimeState;
|
|
||||||
selectedSkills: string[];
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import type { WizardPrompter } from './prompter/interface.js';
|
|
||||||
import type { ConfigService } from './config/config-service.js';
|
|
||||||
import type { WizardState } from './types.js';
|
|
||||||
import { welcomeStage } from './stages/welcome.js';
|
|
||||||
import { detectInstallStage } from './stages/detect-install.js';
|
|
||||||
import { modeSelectStage } from './stages/mode-select.js';
|
|
||||||
import { soulSetupStage } from './stages/soul-setup.js';
|
|
||||||
import { userSetupStage } from './stages/user-setup.js';
|
|
||||||
import { toolsSetupStage } from './stages/tools-setup.js';
|
|
||||||
import { runtimeSetupStage } from './stages/runtime-setup.js';
|
|
||||||
import { skillsSelectStage } from './stages/skills-select.js';
|
|
||||||
import { finalizeStage } from './stages/finalize.js';
|
|
||||||
|
|
||||||
export interface WizardOptions {
|
|
||||||
mosaicHome: string;
|
|
||||||
sourceDir: string;
|
|
||||||
prompter: WizardPrompter;
|
|
||||||
configService: ConfigService;
|
|
||||||
cliOverrides?: Partial<WizardState>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runWizard(options: WizardOptions): Promise<void> {
|
|
||||||
const { prompter, configService, mosaicHome, sourceDir } = options;
|
|
||||||
|
|
||||||
const state: WizardState = {
|
|
||||||
mosaicHome,
|
|
||||||
sourceDir,
|
|
||||||
mode: 'quick',
|
|
||||||
installAction: 'fresh',
|
|
||||||
soul: {},
|
|
||||||
user: {},
|
|
||||||
tools: {},
|
|
||||||
runtimes: { detected: [], mcpConfigured: false },
|
|
||||||
selectedSkills: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply CLI overrides (strip undefined values)
|
|
||||||
if (options.cliOverrides) {
|
|
||||||
if (options.cliOverrides.soul) {
|
|
||||||
for (const [k, v] of Object.entries(options.cliOverrides.soul)) {
|
|
||||||
if (v !== undefined) {
|
|
||||||
(state.soul as Record<string, unknown>)[k] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (options.cliOverrides.user) {
|
|
||||||
for (const [k, v] of Object.entries(options.cliOverrides.user)) {
|
|
||||||
if (v !== undefined) {
|
|
||||||
(state.user as Record<string, unknown>)[k] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (options.cliOverrides.tools) {
|
|
||||||
for (const [k, v] of Object.entries(options.cliOverrides.tools)) {
|
|
||||||
if (v !== undefined) {
|
|
||||||
(state.tools as Record<string, unknown>)[k] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (options.cliOverrides.mode) {
|
|
||||||
state.mode = options.cliOverrides.mode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage 1: Welcome
|
|
||||||
await welcomeStage(prompter, state);
|
|
||||||
|
|
||||||
// Stage 2: Existing Install Detection
|
|
||||||
await detectInstallStage(prompter, state, configService);
|
|
||||||
|
|
||||||
// Stage 3: Quick Start vs Advanced (skip if keeping existing)
|
|
||||||
if (state.installAction === 'fresh' || state.installAction === 'reset') {
|
|
||||||
await modeSelectStage(prompter, state);
|
|
||||||
} else if (state.installAction === 'reconfigure') {
|
|
||||||
state.mode = 'advanced';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage 4: SOUL.md
|
|
||||||
await soulSetupStage(prompter, state);
|
|
||||||
|
|
||||||
// Stage 5: USER.md
|
|
||||||
await userSetupStage(prompter, state);
|
|
||||||
|
|
||||||
// Stage 6: TOOLS.md
|
|
||||||
await toolsSetupStage(prompter, state);
|
|
||||||
|
|
||||||
// Stage 7: Runtime Detection & Installation
|
|
||||||
await runtimeSetupStage(prompter, state);
|
|
||||||
|
|
||||||
// Stage 8: Skills Selection
|
|
||||||
await skillsSelectStage(prompter, state);
|
|
||||||
|
|
||||||
// Stage 9: Finalize
|
|
||||||
await finalizeStage(prompter, state, configService);
|
|
||||||
}
|
|
||||||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@@ -268,15 +268,6 @@ importers:
|
|||||||
|
|
||||||
packages/cli:
|
packages/cli:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mosaic/mosaic':
|
|
||||||
specifier: workspace:^
|
|
||||||
version: link:../mosaic
|
|
||||||
'@mosaic/prdy':
|
|
||||||
specifier: workspace:^
|
|
||||||
version: link:../prdy
|
|
||||||
'@mosaic/quality-rails':
|
|
||||||
specifier: workspace:^
|
|
||||||
version: link:../quality-rails
|
|
||||||
commander:
|
commander:
|
||||||
specifier: ^13.0.0
|
specifier: ^13.0.0
|
||||||
version: 13.1.0
|
version: 13.1.0
|
||||||
@@ -296,9 +287,6 @@ importers:
|
|||||||
specifier: ^4.8.0
|
specifier: ^4.8.0
|
||||||
version: 4.8.3
|
version: 4.8.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
|
||||||
specifier: ^22.0.0
|
|
||||||
version: 22.19.15
|
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.3.0
|
specifier: ^18.3.0
|
||||||
version: 18.3.28
|
version: 18.3.28
|
||||||
@@ -398,26 +386,7 @@ importers:
|
|||||||
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
version: 2.1.9(@types/node@22.19.15)(lightningcss@1.31.1)
|
||||||
|
|
||||||
packages/mosaic:
|
packages/mosaic:
|
||||||
dependencies:
|
|
||||||
'@clack/prompts':
|
|
||||||
specifier: ^0.9.1
|
|
||||||
version: 0.9.1
|
|
||||||
commander:
|
|
||||||
specifier: ^12.1.0
|
|
||||||
version: 12.1.0
|
|
||||||
picocolors:
|
|
||||||
specifier: ^1.1.1
|
|
||||||
version: 1.1.1
|
|
||||||
yaml:
|
|
||||||
specifier: ^2.6.1
|
|
||||||
version: 2.8.2
|
|
||||||
zod:
|
|
||||||
specifier: ^3.23.8
|
|
||||||
version: 3.25.76
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
|
||||||
specifier: ^22.0.0
|
|
||||||
version: 22.19.15
|
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|||||||
Reference in New Issue
Block a user