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

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

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

View File

@@ -9,11 +9,13 @@ This file defines the mandatory behavior for all Mosaic agent runtimes.
Before responding to any user message, you MUST read these files in order:
1. `~/.config/mosaic/SOUL.md`
2. `~/.config/mosaic/STANDARDS.md`
3. `~/.config/mosaic/AGENTS.md`
4. `~/.config/mosaic/guides/E2E-DELIVERY.md`
5. Project-local `AGENTS.md` (if present)
6. Runtime-specific reference:
2. `~/.config/mosaic/USER.md`
3. `~/.config/mosaic/STANDARDS.md`
4. `~/.config/mosaic/AGENTS.md`
5. `~/.config/mosaic/TOOLS.md`
6. `~/.config/mosaic/guides/E2E-DELIVERY.md`
7. Project-local `AGENTS.md` (if present)
8. Runtime-specific reference:
- Claude: `~/.config/mosaic/runtime/claude/RUNTIME.md`
- Codex: `~/.config/mosaic/runtime/codex/RUNTIME.md`
- OpenCode: `~/.config/mosaic/runtime/opencode/RUNTIME.md`

View File

@@ -4,6 +4,8 @@ Universal agent standards layer for Claude Code, Codex, and OpenCode.
One config, every runtime, same standards.
> **This repository is a generic framework baseline.** No personal data, credentials, user-specific preferences, or machine-specific paths should be committed. All personalization happens at install time via `mosaic init` or by editing files in `~/.config/mosaic/` after installation.
## Quick Install
### Mac / Linux
@@ -42,7 +44,10 @@ After install, open a new terminal (or `source ~/.bashrc`) and run:
mosaic init
```
This generates `~/.config/mosaic/SOUL.md` — your agent identity contract. It's loaded into every session regardless of runtime.
This generates three files loaded into every agent session:
- `SOUL.md` — agent identity contract (name, style, guardrails)
- `USER.md` — your user profile (name, timezone, accessibility, preferences)
- `TOOLS.md` — machine-level tool reference (git providers, credentials, CLI patterns)
## Launching Agent Sessions
@@ -65,7 +70,9 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
```
~/.config/mosaic/
├── AGENTS.md ← THE source of truth (all standards, all runtimes)
├── SOUL.md ← User identity (generated by mosaic init)
├── SOUL.md ← Agent identity (generated by mosaic init)
├── USER.md ← User profile and accessibility (generated by mosaic init)
├── TOOLS.md ← Machine-level tool reference (generated by mosaic init)
├── STANDARDS.md ← Machine-wide standards
├── guides/E2E-DELIVERY.md ← Mandatory E2E software delivery procedure
├── guides/PRD.md ← Mandatory PRD requirements gate before coding
@@ -121,7 +128,7 @@ mosaic upgrade project ... # Project file cleanup mode (see below)
Upgrade the installed framework in place:
```bash
# Default (safe): keep local SOUL.md + memory
# Default (safe): keep local SOUL.md, USER.md, TOOLS.md + memory
mosaic upgrade
# Check current/target release info without changing files
@@ -245,7 +252,7 @@ cd ~/src/mosaic-bootstrap && git pull && bash install.sh
```
If an existing install is detected, the installer prompts for:
- `keep` (recommended): preserve local `SOUL.md` and `memory/`
- `keep` (recommended): preserve local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/`
- `overwrite`: replace everything in `~/.config/mosaic`
Or use the one-liner again — it always pulls the latest:

View File

@@ -5,14 +5,14 @@ It is loaded globally and applies to all sessions regardless of runtime or proje
## Identity
You are **Jarvis** in this session.
You are **Assistant** in this session.
- Runtime (Claude, Codex, OpenCode, etc.) is implementation detail.
- Role identity: execution partner and visibility engine
If asked "who are you?", answer:
`I am Jarvis, running on <runtime>.`
`I am Assistant, running on <runtime>.`
## Behavioral Principles
@@ -20,7 +20,7 @@ If asked "who are you?", answer:
2. Practical execution over abstract planning.
3. Truthfulness over confidence: state uncertainty explicitly.
4. Visible state over hidden assumptions.
5. PDA-friendly language, communication style, and iconography. Avoid overwhelming info and communication style..
5. Accessibility-aware — see `~/.config/mosaic/USER.md` for user-specific accommodations.
## Communication Style
@@ -42,7 +42,8 @@ If asked "who are you?", answer:
- Do not perform destructive actions without explicit instruction.
- Do not silently change intent, scope, or definitions.
- Do not create fake policy by writing canned responses for every prompt.
- Prefer `trash` over `rm` when available — recoverable beats gone forever.
- Write decisions and learnings to files — "mental notes" do not survive session restarts.
## Why This Exists

60
TOOLS.md Normal file
View File

@@ -0,0 +1,60 @@
# Machine-Level Tool Reference
Centralized reference for tools, credentials, and CLI patterns available across all projects.
Project-specific tooling belongs in the project's `AGENTS.md`, not here.
## Mosaic Git Wrappers (Use First)
Mosaic wrappers at `~/.config/mosaic/rails/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
```bash
# Issues
~/.config/mosaic/rails/git/issue-create.sh
~/.config/mosaic/rails/git/issue-close.sh
# PRs
~/.config/mosaic/rails/git/pr-create.sh
~/.config/mosaic/rails/git/pr-merge.sh
# Milestones
~/.config/mosaic/rails/git/milestone-create.sh
# CI queue guard (required before push/merge)
~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge
```
## Code Review (Codex)
```bash
# Code quality review
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted
# Security review
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
```
## Git Providers
| Instance | URL | CLI | Purpose |
|----------|-----|-----|---------|
| (add your git providers here) | | | |
## Credentials
**Location:** (configure your credential file path)
**Never expose actual values. Never commit credential files.**
## CLI Gotchas
(Add platform-specific CLI gotchas as you discover them. Examples: TTY requirements, default list limits, API fallback patterns.)
## Custom Tools
(Add any machine-specific tools, scripts, or workflows here.)
## Safety Defaults
- Prefer `trash` over `rm` when available — recoverable beats gone forever
- Never run destructive commands without explicit instruction
- Write it down — "mental notes" don't survive session restarts; files do

37
USER.md Normal file
View File

@@ -0,0 +1,37 @@
# User Profile
This file defines user-specific context for all agent sessions.
It is loaded globally and applies regardless of runtime or project.
> **This file has not been personalized yet.**
> Run `mosaic init` to set up your user profile, or edit this file directly.
## Identity
- **Name:** (not configured)
- **Pronouns:** (not configured)
- **Timezone:** (not configured)
## Background
(Run `mosaic init` or edit this section with your professional background.)
## Accessibility
(Add any neurodivergence accommodations, communication preferences, or accessibility needs here. Agents will adapt their behavior based on this section.)
## Communication Preferences
- Direct and concise
- No sycophancy
- Executive summaries and tables for overview
## Personal Boundaries
(Add any personal boundaries or preferences agents should respect.)
## Current Projects
| Project | Stack | Registry |
|---------|-------|----------|
| (none configured) | | |

View File

@@ -148,6 +148,17 @@ For required push/merge/issue-close/release actions, execute without routine con
EOF
cat "$MOSAIC_HOME/AGENTS.md"
if [[ -f "$MOSAIC_HOME/USER.md" ]]; then
printf '\n\n# User Profile\n\n'
cat "$MOSAIC_HOME/USER.md"
fi
if [[ -f "$MOSAIC_HOME/TOOLS.md" ]]; then
printf '\n\n# Machine Tools\n\n'
cat "$MOSAIC_HOME/TOOLS.md"
fi
printf '\n\n# Runtime-Specific Contract\n\n'
cat "$runtime_file"
}
@@ -266,6 +277,12 @@ launch_yolo() {
# Delegate to existing scripts
run_init() {
# Prefer wizard if Node.js and bundle are available
local wizard_bin="$MOSAIC_HOME/dist/mosaic-wizard.mjs"
if command -v node >/dev/null 2>&1 && [[ -f "$wizard_bin" ]]; then
exec node "$wizard_bin" "$@"
fi
# Fallback to legacy bash wizard
check_mosaic_home
exec "$MOSAIC_HOME/bin/mosaic-init" "$@"
}

View File

@@ -146,6 +146,8 @@ echo "[mosaic-doctor] Mosaic home: $MOSAIC_HOME"
# Canonical Mosaic checks
expect_file "$MOSAIC_HOME/STANDARDS.md"
expect_file "$MOSAIC_HOME/USER.md"
expect_file "$MOSAIC_HOME/TOOLS.md"
expect_dir "$MOSAIC_HOME/guides"
expect_dir "$MOSAIC_HOME/rails"
expect_dir "$MOSAIC_HOME/rails/quality"

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# mosaic-init — Interactive SOUL.md generator
# mosaic-init — Interactive agent identity, user profile, and tool config generator
#
# Usage:
# mosaic-init # Interactive mode
@@ -10,8 +10,12 @@ set -euo pipefail
# --accessibility "ADHD-friendly chunking" --guardrails "Never auto-commit"
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
TEMPLATE="$MOSAIC_HOME/templates/SOUL.md.template"
OUTPUT="$MOSAIC_HOME/SOUL.md"
SOUL_TEMPLATE="$MOSAIC_HOME/templates/SOUL.md.template"
USER_TEMPLATE="$MOSAIC_HOME/templates/USER.md.template"
TOOLS_TEMPLATE="$MOSAIC_HOME/templates/TOOLS.md.template"
SOUL_OUTPUT="$MOSAIC_HOME/SOUL.md"
USER_OUTPUT="$MOSAIC_HOME/USER.md"
TOOLS_OUTPUT="$MOSAIC_HOME/TOOLS.md"
# Defaults
AGENT_NAME=""
@@ -20,11 +24,28 @@ STYLE=""
ACCESSIBILITY=""
CUSTOM_GUARDRAILS=""
# USER.md defaults
USER_NAME=""
PRONOUNS=""
TIMEZONE=""
BACKGROUND=""
COMMUNICATION_PREFS=""
PERSONAL_BOUNDARIES=""
PROJECTS_TABLE=""
# TOOLS.md defaults
GIT_PROVIDERS_TABLE=""
CREDENTIALS_LOCATION=""
CUSTOM_TOOLS_SECTION=""
usage() {
cat <<USAGE
Usage: $(basename "$0") [options]
Generate ~/.config/mosaic/SOUL.md — the universal agent identity contract.
Generate Mosaic identity and configuration files:
- SOUL.md — Agent identity contract
- USER.md — User profile and accessibility
- TOOLS.md — Machine-level tool reference
Interactive by default. Use flags to skip prompts.
@@ -34,12 +55,17 @@ Options:
--style <style> Communication style: direct, friendly, or formal
--accessibility <prefs> Accessibility preferences (e.g., "ADHD-friendly chunking")
--guardrails <rules> Custom guardrails (appended to defaults)
--user-name <name> Your name for USER.md
--pronouns <pronouns> Your pronouns (e.g., "He/Him")
--timezone <tz> Your timezone (e.g., "America/Chicago")
--non-interactive Fail if any required value is missing (no prompts)
--soul-only Only generate SOUL.md
-h, --help Show help
USAGE
}
NON_INTERACTIVE=0
SOUL_ONLY=0
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -48,7 +74,11 @@ while [[ $# -gt 0 ]]; do
--style) STYLE="$2"; shift 2 ;;
--accessibility) ACCESSIBILITY="$2"; shift 2 ;;
--guardrails) CUSTOM_GUARDRAILS="$2"; shift 2 ;;
--user-name) USER_NAME="$2"; shift 2 ;;
--pronouns) PRONOUNS="$2"; shift 2 ;;
--timezone) TIMEZONE="$2"; shift 2 ;;
--non-interactive) NON_INTERACTIVE=1; shift ;;
--soul-only) SOUL_ONLY=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;;
esac
@@ -85,7 +115,32 @@ prompt_if_empty() {
eval "$var_name=\"$value\""
}
echo "[mosaic-init] Generating SOUL.md — your universal agent identity contract"
prompt_multiline() {
local var_name="$1"
local prompt_text="$2"
local default_value="${3:-}"
local current_value="${!var_name}"
if [[ -n "$current_value" ]]; then
return
fi
if [[ $NON_INTERACTIVE -eq 1 ]]; then
eval "$var_name=\"$default_value\""
return
fi
echo "$prompt_text"
printf "(Press Enter to skip, or type your response): "
read -r value
if [[ -z "$value" ]]; then
value="$default_value"
fi
eval "$var_name=\"$value\""
}
# ── SOUL.md Generation ────────────────────────────────────────
echo "[mosaic-init] Generating SOUL.md — agent identity contract"
echo ""
prompt_if_empty AGENT_NAME "What name should agents use" "Assistant"
@@ -124,25 +179,28 @@ case "$STYLE" in
BEHAVIORAL_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."
4. Visible state over hidden assumptions.
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations."
;;
friendly)
BEHAVIORAL_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."
4. Visible state over hidden assumptions.
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations."
;;
formal)
BEHAVIORAL_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."
4. Document decisions and rationale clearly.
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations."
;;
esac
if [[ "$ACCESSIBILITY" != "none" && -n "$ACCESSIBILITY" ]]; then
BEHAVIORAL_PRINCIPLES="$BEHAVIORAL_PRINCIPLES
5. $ACCESSIBILITY."
6. $ACCESSIBILITY."
fi
# Build communication style section
@@ -175,8 +233,8 @@ if [[ -n "$CUSTOM_GUARDRAILS" ]]; then
fi
# Verify template exists
if [[ ! -f "$TEMPLATE" ]]; then
echo "[mosaic-init] ERROR: Template not found: $TEMPLATE" >&2
if [[ ! -f "$SOUL_TEMPLATE" ]]; then
echo "[mosaic-init] ERROR: Template not found: $SOUL_TEMPLATE" >&2
echo "[mosaic-init] Run the Mosaic installer first." >&2
exit 1
fi
@@ -194,17 +252,173 @@ awk -v name="$AGENT_NAME" \
gsub(/\{\{COMMUNICATION_STYLE\}\}/, comms)
gsub(/\{\{CUSTOM_GUARDRAILS\}\}/, guardrails)
print
}' "$TEMPLATE" > "$OUTPUT"
}' "$SOUL_TEMPLATE" > "$SOUL_OUTPUT"
echo ""
echo "[mosaic-init] Generated: $OUTPUT"
echo "[mosaic-init] Generated: $SOUL_OUTPUT"
echo "[mosaic-init] Agent name: $AGENT_NAME"
echo "[mosaic-init] Style: $STYLE"
# Push to runtime adapters
if [[ $SOUL_ONLY -eq 1 ]]; then
# Push to runtime adapters and exit
if [[ -x "$MOSAIC_HOME/bin/mosaic-link-runtime-assets" ]]; then
echo "[mosaic-init] Updating runtime adapters..."
"$MOSAIC_HOME/bin/mosaic-link-runtime-assets"
fi
echo "[mosaic-init] Done. Launch with: mosaic claude"
exit 0
fi
# ── USER.md Generation ────────────────────────────────────────
echo ""
echo "[mosaic-init] Generating USER.md — user profile"
echo ""
prompt_if_empty USER_NAME "Your name" ""
prompt_if_empty PRONOUNS "Your pronouns" "They/Them"
prompt_if_empty TIMEZONE "Your timezone" "UTC"
prompt_multiline BACKGROUND "Your professional background (brief summary)" "(not configured)"
# Build accessibility section
ACCESSIBILITY_SECTION=""
if [[ "$ACCESSIBILITY" != "none" && -n "$ACCESSIBILITY" ]]; then
ACCESSIBILITY_SECTION="$ACCESSIBILITY"
else
if [[ $NON_INTERACTIVE -eq 0 ]]; then
echo ""
prompt_multiline ACCESSIBILITY_SECTION \
"Accessibility or neurodivergence accommodations (or press Enter to skip)" \
"(No specific accommodations configured. Edit this section to add any.)"
else
ACCESSIBILITY_SECTION="(No specific accommodations configured. Edit this section to add any.)"
fi
fi
# Build communication preferences
if [[ -z "$COMMUNICATION_PREFS" ]]; then
case "$STYLE" in
direct)
COMMUNICATION_PREFS="- Direct and concise
- No sycophancy
- Executive summaries and tables for overview"
;;
friendly)
COMMUNICATION_PREFS="- Warm and conversational
- Explain reasoning when helpful
- Balance thoroughness with brevity"
;;
formal)
COMMUNICATION_PREFS="- Professional and structured
- Thorough explanations with supporting detail
- Formal tone with explicit recommendations"
;;
esac
fi
prompt_multiline PERSONAL_BOUNDARIES \
"Personal boundaries or preferences agents should respect" \
"(Edit this section to add any personal boundaries.)"
if [[ -z "$PROJECTS_TABLE" ]]; then
PROJECTS_TABLE="| Project | Stack | Registry |
|---------|-------|----------|
| (none configured) | | |"
fi
if [[ ! -f "$USER_TEMPLATE" ]]; then
echo "[mosaic-init] WARN: USER.md template not found: $USER_TEMPLATE" >&2
echo "[mosaic-init] Skipping USER.md generation." >&2
else
awk -v user_name="$USER_NAME" \
-v pronouns="$PRONOUNS" \
-v timezone="$TIMEZONE" \
-v background="$BACKGROUND" \
-v accessibility="$ACCESSIBILITY_SECTION" \
-v comms="$COMMUNICATION_PREFS" \
-v boundaries="$PERSONAL_BOUNDARIES" \
-v projects="$PROJECTS_TABLE" \
'{
gsub(/\{\{USER_NAME\}\}/, user_name)
gsub(/\{\{PRONOUNS\}\}/, pronouns)
gsub(/\{\{TIMEZONE\}\}/, timezone)
gsub(/\{\{BACKGROUND\}\}/, background)
gsub(/\{\{ACCESSIBILITY_SECTION\}\}/, accessibility)
gsub(/\{\{COMMUNICATION_PREFS\}\}/, comms)
gsub(/\{\{PERSONAL_BOUNDARIES\}\}/, boundaries)
gsub(/\{\{PROJECTS_TABLE\}\}/, projects)
print
}' "$USER_TEMPLATE" > "$USER_OUTPUT"
echo "[mosaic-init] Generated: $USER_OUTPUT"
fi
# ── TOOLS.md Generation ───────────────────────────────────────
echo ""
echo "[mosaic-init] Generating TOOLS.md — machine-level tool reference"
echo ""
if [[ -z "$GIT_PROVIDERS_TABLE" ]]; then
if [[ $NON_INTERACTIVE -eq 0 ]]; then
echo "Git providers (add rows for your Gitea/GitHub/GitLab instances):"
printf "Primary git provider URL (or press Enter to skip): "
read -r git_url
if [[ -n "$git_url" ]]; then
printf "Provider name: "
read -r git_name
printf "CLI tool (tea/gh/glab): "
read -r git_cli
printf "Purpose: "
read -r git_purpose
GIT_PROVIDERS_TABLE="| Instance | URL | CLI | Purpose |
|----------|-----|-----|---------|
| $git_name | $git_url | \`$git_cli\` | $git_purpose |"
else
GIT_PROVIDERS_TABLE="| Instance | URL | CLI | Purpose |
|----------|-----|-----|---------|
| (add your git providers here) | | | |"
fi
else
GIT_PROVIDERS_TABLE="| Instance | URL | CLI | Purpose |
|----------|-----|-----|---------|
| (add your git providers here) | | | |"
fi
fi
prompt_if_empty CREDENTIALS_LOCATION "Credential file path (or 'none')" "none"
if [[ -z "$CUSTOM_TOOLS_SECTION" ]]; then
CUSTOM_TOOLS_SECTION="## Custom Tools
(Add any machine-specific tools, scripts, or workflows here.)"
fi
if [[ ! -f "$TOOLS_TEMPLATE" ]]; then
echo "[mosaic-init] WARN: TOOLS.md template not found: $TOOLS_TEMPLATE" >&2
echo "[mosaic-init] Skipping TOOLS.md generation." >&2
else
awk -v providers="$GIT_PROVIDERS_TABLE" \
-v creds="$CREDENTIALS_LOCATION" \
-v custom="$CUSTOM_TOOLS_SECTION" \
'{
gsub(/\{\{GIT_PROVIDERS_TABLE\}\}/, providers)
gsub(/\{\{CREDENTIALS_LOCATION\}\}/, creds)
gsub(/\{\{CUSTOM_TOOLS_SECTION\}\}/, custom)
print
}' "$TOOLS_TEMPLATE" > "$TOOLS_OUTPUT"
echo "[mosaic-init] Generated: $TOOLS_OUTPUT"
fi
# ── Finalize ──────────────────────────────────────────────────
# Push to runtime adapters
if [[ -x "$MOSAIC_HOME/bin/mosaic-link-runtime-assets" ]]; then
echo ""
echo "[mosaic-init] Updating runtime adapters..."
"$MOSAIC_HOME/bin/mosaic-link-runtime-assets"
fi
echo ""
echo "[mosaic-init] Done. Launch with: mosaic claude"
echo "[mosaic-init] Edit USER.md and TOOLS.md directly for further customization."

25
bin/mosaic-wizard Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
# mosaic-wizard — Thin shell wrapper for the bundled TypeScript wizard
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
# Look for the bundle in the installed location first, then the source repo
WIZARD_BIN="$MOSAIC_HOME/dist/mosaic-wizard.mjs"
if [[ ! -f "$WIZARD_BIN" ]]; then
WIZARD_BIN="$(cd "$(dirname "$0")/.." && pwd)/dist/mosaic-wizard.mjs"
fi
if [[ ! -f "$WIZARD_BIN" ]]; then
echo "[mosaic-wizard] ERROR: Wizard bundle not found." >&2
echo "[mosaic-wizard] Run 'pnpm build' in the mosaic-bootstrap repo, or re-install Mosaic." >&2
exit 1
fi
if ! command -v node >/dev/null 2>&1; then
echo "[mosaic-wizard] ERROR: Node.js is required but not found." >&2
echo "[mosaic-wizard] Install Node.js 18+ from https://nodejs.org" >&2
exit 1
fi
exec node "$WIZARD_BIN" "$@"

15690
dist/mosaic-wizard.mjs vendored Executable file

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@ set -euo pipefail
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}" # prompt|keep|overwrite
PRESERVE_PATHS=("SOUL.md" "memory")
PRESERVE_PATHS=("SOUL.md" "USER.md" "TOOLS.md" "memory")
# Colors (disabled if not a terminal)
if [[ -t 1 ]]; then
@@ -46,7 +46,7 @@ select_install_mode() {
echo ""
echo "Existing Mosaic install detected at: $TARGET_DIR"
echo "Choose reinstall mode:"
echo " 1) keep Keep local files (SOUL.md, memory/) while updating framework"
echo " 1) keep Keep local files (SOUL.md, USER.md, TOOLS.md, memory/) while updating framework"
echo " 2) overwrite Replace everything in $TARGET_DIR"
echo " 3) cancel Abort install"
printf "Selection [1/2/3] (default: 1): "
@@ -131,7 +131,7 @@ mkdir -p "$TARGET_DIR"
select_install_mode
if [[ "$INSTALL_MODE" == "keep" ]]; then
ok "Install mode: keep local SOUL.md/memory while updating framework"
ok "Install mode: keep local SOUL.md/USER.md/TOOLS.md/memory while updating framework"
else
ok "Install mode: overwrite existing files"
fi
@@ -226,7 +226,9 @@ if [[ "$PATH_CHANGED" == "true" ]]; then
fi
if [[ ! -f "$TARGET_DIR/SOUL.md" ]]; then
NEXT_STEPS+=("Run ${CYAN}mosaic init${RESET} to set up your agent identity (SOUL.md).")
NEXT_STEPS+=("Run ${CYAN}mosaic init${RESET} to set up your agent identity (SOUL.md), user profile (USER.md), and tool config (TOOLS.md).")
elif grep -q "not configured" "$TARGET_DIR/USER.md" 2>/dev/null; then
NEXT_STEPS+=("Run ${CYAN}mosaic init${RESET} to personalize your user profile (USER.md) and tool config (TOOLS.md).")
fi
if [[ ${#NEXT_STEPS[@]} -gt 0 ]]; then

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "mosaic-wizard",
"version": "0.2.0",
"type": "module",
"private": true,
"bin": {
"mosaic-wizard": "./dist/mosaic-wizard.mjs"
},
"scripts": {
"build": "tsdown",
"dev": "tsx src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@clack/prompts": "^0.9",
"commander": "^13",
"picocolors": "^1.1",
"yaml": "^2.7",
"zod": "^3.24"
},
"devDependencies": {
"@types/node": "^22",
"tsdown": "^0.20.3",
"tsx": "^4.19",
"typescript": "^5.7",
"vitest": "^3.0"
}
}

1628
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -37,8 +37,21 @@ if [ ! -f "$WORK_DIR/bootstrap/install.sh" ]; then
exit 1
fi
echo "[mosaic] Running install..."
cd "$WORK_DIR/bootstrap"
# Prefer TypeScript wizard if Node.js 18+ and bundle are available
WIZARD_BIN="$WORK_DIR/bootstrap/dist/mosaic-wizard.mjs"
if command -v node >/dev/null 2>&1 && [ -f "$WIZARD_BIN" ]; then
NODE_MAJOR="$(node -e 'console.log(process.versions.node.split(".")[0])')"
if [ "$NODE_MAJOR" -ge 18 ] 2>/dev/null; then
echo "[mosaic] Running wizard installer (Node.js $NODE_MAJOR detected)..."
node "$WIZARD_BIN" --source-dir "$WORK_DIR/bootstrap"
echo "[mosaic] Cleaning up temporary files..."
exit 0
fi
fi
echo "[mosaic] Running legacy install..."
bash install.sh
echo "[mosaic] Cleaning up temporary files..."

View File

@@ -0,0 +1,26 @@
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);
}

163
src/config/file-adapter.ts Normal file
View File

@@ -0,0 +1,163 @@
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) config.agentName = nameMatch[1];
const roleMatch = content.match(/Role identity: (.+)/);
if (roleMatch) 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) config.userName = nameMatch[1];
const pronounsMatch = content.match(/\*\*Pronouns:\*\* (.+)/);
if (pronounsMatch) config.pronouns = pronounsMatch[1];
const tzMatch = content.match(/\*\*Timezone:\*\* (.+)/);
if (tzMatch) 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) 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;
}
}

51
src/config/schemas.ts Normal file
View File

@@ -0,0 +1,51 @@
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();

38
src/constants.ts Normal file
View File

@@ -0,0 +1,38 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
export const VERSION = '0.2.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',
]);

20
src/errors.ts Normal file
View File

@@ -0,0 +1,20 @@
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}`);
}
}

81
src/index.ts Normal file
View File

@@ -0,0 +1,81 @@
import { Command } from 'commander';
import { homedir } from 'node:os';
import { join } from 'node:path';
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';
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) => {
try {
const mosaicHome: string = opts.mosaicHome;
const sourceDir: string = opts.sourceDir ?? 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,
roleDescription: opts.role,
communicationStyle: style,
accessibility: opts.accessibility,
customGuardrails: opts.guardrails,
},
user: {
userName: opts.userName,
pronouns: opts.pronouns,
timezone: opts.timezone,
},
},
});
} catch (err) {
if (err instanceof WizardCancelledError) {
console.log('\nWizard cancelled.');
process.exit(0);
}
console.error('Wizard failed:', err);
process.exit(1);
}
});
program.parse();

44
src/platform/detect.ts Normal file
View File

@@ -0,0 +1,44 @@
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');
}
}

116
src/platform/file-ops.ts Normal file
View File

@@ -0,0 +1,116 @@
import {
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
copyFileSync,
renameSync,
readdirSync,
unlinkSync,
cpSync,
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}`;
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) => f.startsWith(prefix))
.sort()
.reverse();
for (let i = MAX_BACKUPS; i < backups.length; i++) {
unlinkSync(join(dir, backups[i]));
}
} 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;
}
}

View File

@@ -0,0 +1,157 @@
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,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- clack Option conditional type needs concrete Primitive
const result = await p.select({
message: opts.message,
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,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await p.multiselect({
message: opts.message,
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,
}));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await p.groupMultiselect({
message: opts.message,
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('');
}
}

View File

@@ -0,0 +1,133 @@
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}"`);
}
return opts.options[0].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.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.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];
}
}

56
src/prompter/interface.ts Normal file
View File

@@ -0,0 +1,56 @@
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;
}

83
src/runtime/detector.ts Normal file
View File

@@ -0,0 +1,83 @@
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 path = 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,
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;
}

12
src/runtime/installer.ts Normal file
View File

@@ -0,0 +1,12 @@
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}`;
}

112
src/runtime/mcp-config.ts Normal file
View File

@@ -0,0 +1,112 @@
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'));
} 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'));
} 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',
);
}

99
src/skills/catalog.ts Normal file
View File

@@ -0,0 +1,99 @@
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) ?? entry.name,
description: (frontmatter.description as string) ?? '',
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) 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) {
result[kv[1]] = kv[2].replace(/^['"]|['"]$/g, '');
}
}
return result;
}
}

86
src/skills/categories.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* 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';
}

View File

@@ -0,0 +1,95 @@
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();
}
}

177
src/stages/finalize.ts Normal file
View File

@@ -0,0 +1,177 @@
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} selected`,
`Config: ${state.mosaicHome}`,
];
if (doctorResult.warnings > 0) {
summary.push(
`Health: ${doctorResult.warnings} 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}. ${s}`).join('\n'),
'Next Steps',
);
p.outro('Mosaic is ready.');
}

23
src/stages/mode-select.ts Normal file
View File

@@ -0,0 +1,23 @@
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',
},
],
});
}

View File

@@ -0,0 +1,70 @@
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})`,
)
.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} 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.`,
);
}
}
}

View File

@@ -0,0 +1,84 @@
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} 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} 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) delete grouped[key];
}
state.selectedSkills = await p.groupMultiselect({
message: 'Select skills (space to toggle)',
options: grouped,
required: false,
});
}

73
src/stages/soul-setup.ts Normal file
View File

@@ -0,0 +1,73 @@
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';
},
});
}
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: '',
});
}
}
}

76
src/stages/tools-setup.ts Normal file
View File

@@ -0,0 +1,76 @@
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,
});
}

80
src/stages/user-setup.ts Normal file
View File

@@ -0,0 +1,80 @@
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',
);
}

18
src/stages/welcome.ts Normal file
View File

@@ -0,0 +1,18 @@
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?',
);
}

145
src/template/builders.ts Normal file
View File

@@ -0,0 +1,145 @@
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.
*/
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,
};
}
export { buildCommunicationPrefs };

26
src/template/engine.ts Normal file
View File

@@ -0,0 +1,26 @@
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 '';
},
);
}

53
src/types.ts Normal file
View File

@@ -0,0 +1,53 @@
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[];
}

96
src/wizard.ts Normal file
View File

@@ -0,0 +1,96 @@
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);
}

View File

@@ -35,6 +35,8 @@ If asked "who are you?", answer:
- Do not perform destructive actions without explicit instruction.
- Do not silently change intent, scope, or definitions.
- Do not create fake policy by writing canned responses for every prompt.
- Prefer `trash` over `rm` when available — recoverable beats gone forever.
- Write decisions and learnings to files — "mental notes" do not survive session restarts.
{{CUSTOM_GUARDRAILS}}
## Why This Exists

View File

@@ -0,0 +1,56 @@
# Machine-Level Tool Reference
Centralized reference for tools, credentials, and CLI patterns available across all projects.
Project-specific tooling belongs in the project's `AGENTS.md`, not here.
## Mosaic Git Wrappers (Use First)
Mosaic wrappers at `~/.config/mosaic/rails/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
```bash
# Issues
~/.config/mosaic/rails/git/issue-create.sh
~/.config/mosaic/rails/git/issue-close.sh
# PRs
~/.config/mosaic/rails/git/pr-create.sh
~/.config/mosaic/rails/git/pr-merge.sh
# Milestones
~/.config/mosaic/rails/git/milestone-create.sh
# CI queue guard (required before push/merge)
~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge
```
## Code Review (Codex)
```bash
# Code quality review
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted
# Security review
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
```
## Git Providers
{{GIT_PROVIDERS_TABLE}}
## Credentials
**Location:** {{CREDENTIALS_LOCATION}}
**Never expose actual values. Never commit credential files.**
## CLI Gotchas
(Add platform-specific CLI gotchas as you discover them.)
{{CUSTOM_TOOLS_SECTION}}
## Safety Defaults
- Prefer `trash` over `rm` when available — recoverable beats gone forever
- Never run destructive commands without explicit instruction
- Write it down — "mental notes" don't survive session restarts; files do

View File

@@ -0,0 +1,30 @@
# User Profile
This file defines user-specific context for all agent sessions.
It is loaded globally and applies regardless of runtime or project.
## Identity
- **Name:** {{USER_NAME}}
- **Pronouns:** {{PRONOUNS}}
- **Timezone:** {{TIMEZONE}}
## Background
{{BACKGROUND}}
## Accessibility
{{ACCESSIBILITY_SECTION}}
## Communication Preferences
{{COMMUNICATION_PREFS}}
## Personal Boundaries
{{PERSONAL_BOUNDARIES}}
## Current Projects
{{PROJECTS_TABLE}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

19
tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"sourceMap": false,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}

13
tsdown.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
entry: { 'mosaic-wizard': 'src/index.ts' },
format: 'esm',
outDir: 'dist',
clean: true,
noExternal: [/.*/],
inlineOnly: false,
banner: { js: '#!/usr/bin/env node' },
target: 'node18',
minify: false,
});

9
vitest.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
globals: true,
},
});