Compare commits

...

8 Commits

Author SHA1 Message Date
f73cc59b6d chore(release): @mosaicstack/mosaic 0.0.29
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
Changes since 0.0.28:
- feat(framework): superpowers enforcement, typecheck hook, file-ownership rules (#451)
- fix(installer): preserve credentials dir and seed STANDARDS.md (#452)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 19:42:17 -05:00
f64ec12f39 fix(installer): preserve credentials dir and seed STANDARDS.md (#452)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-08 00:40:49 +00:00
026382325c feat(framework): superpowers enforcement, typecheck hook, file-ownership rules (#451)
All checks were successful
ci/woodpecker/manual/ci Pipeline was successful
ci/woodpecker/manual/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-04-07 00:44:22 +00:00
1bfd8570d6 chore(release): @mosaicstack/mosaic 0.0.28 (#450) 2026-04-06 00:46:31 +00:00
312acd8bad chore: sweep mosaicstack/mosaic-stack → mosaicstack/stack + add short install URL (#448) 2026-04-06 00:39:56 +00:00
d08b969918 fix(mosaic): mask password input in TUI login prompt (#449) 2026-04-06 00:33:54 +00:00
051de0d8a9 docs: update README for mosaicstack/stack repo rename (#447) 2026-04-06 00:22:20 +00:00
bd76df1a50 feat(mosaic): drill-down main menu + provider-first flow + quick start (#446) 2026-04-06 00:15:23 +00:00
48 changed files with 1605 additions and 107 deletions

View File

@@ -103,12 +103,12 @@ steps:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:latest"
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:$CI_COMMIT_TAG"
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
depends_on:
@@ -128,12 +128,12 @@ steps:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:sha-${CI_COMMIT_SHA:0:7}"
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:latest"
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:$CI_COMMIT_TAG"
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
depends_on:

View File

@@ -59,9 +59,9 @@ pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
| Value | When to use | Budget |
| -------- | ----------------------------------------------------------- | -------------------------- |
| --------- | ----------------------------------------------------------- | -------------------------- |
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
| `glm-5` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
| `glm-5.1` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |

View File

@@ -7,7 +7,13 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
## Quick Install
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
curl -fsSL https://mosaicstack.dev/install.sh | bash
```
Or use the direct URL:
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
```
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
@@ -179,8 +185,8 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
### Setup
```bash
git clone git@git.mosaicstack.dev:mosaicstack/mosaic-stack.git
cd mosaic-stack
git clone git@git.mosaicstack.dev:mosaicstack/stack.git
cd stack
# Start infrastructure (Postgres, Valkey, Jaeger)
docker compose up -d
@@ -229,7 +235,7 @@ npm packages are published to the Gitea package registry on main merges.
## Architecture
```
mosaic-stack/
stack/
├── apps/
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
│ └── web/ Next.js dashboard (React 19, Tailwind)
@@ -302,7 +308,13 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
Run the installer again — it handles upgrades automatically:
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
curl -fsSL https://mosaicstack.dev/install.sh | bash
```
Or use the direct URL:
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
```
Or use the CLI:

View File

@@ -3,7 +3,7 @@
"version": "0.0.6",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "apps/gateway"
},
"type": "module",

View File

@@ -165,7 +165,13 @@ The `mosaic` CLI provides a terminal interface to the same gateway API.
Install via the Mosaic installer:
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
curl -fsSL https://mosaicstack.dev/install.sh | bash
```
Or use the direct URL:
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
```
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for

View File

@@ -0,0 +1,227 @@
# IUV-M03 Design: Provider-first intelligent flow + drill-down main menu
**Issue:** #438
**Branch:** `feat/install-ux-intent`
**Date:** 2026-04-05
## 1. New first-run state machine
The linear 12-stage interrogation is replaced with a menu-driven architecture.
### Flow overview
```
Welcome banner
|
v
Detect existing install (auto)
|
v
Main Menu (loop)
|-- Quick Start -> provider key + admin creds -> finalize
|-- Providers -> LLM API key config
|-- Agent Identity -> intent intake + naming (deterministic)
|-- Skills -> recommended / custom selection
|-- Gateway -> port, storage tier, hostname, CORS
|-- Advanced -> SOUL.md, USER.md, TOOLS.md, runtimes, hooks
|-- Finish & Apply -> finalize + gateway bootstrap
v
Done
```
### Menu navigation
- Main menu is a `select` prompt. Each option drills into a sub-flow.
- Completing a section returns to the main menu.
- Menu items show completion state: `[done]` hint after configuration.
- `Finish & Apply` is always last and requires at minimum a provider key (or explicit skip).
- The menu tracks configured sections in `WizardState.completedSections`.
### Headless bypass
When `MOSAIC_ASSUME_YES=1` or `!process.stdin.isTTY`, the entire menu is skipped.
The wizard runs: defaults + env var overrides -> finalize -> gateway config -> bootstrap.
This preserves full backward compatibility with `tools/install.sh --yes`.
## 2. Quick Start path
Target: 3-5 questions max. Under 90 seconds for a returning user.
### Questions asked
1. **Provider API key** (Anthropic/OpenAI) - `text` prompt with paste support
2. **Admin email** - `text` prompt
3. **Admin password** - masked + confirmed
### Questions skipped (with defaults)
| Setting | Default | Rationale |
| ---------------------------- | ------------------------------- | ---------------------- |
| Agent name | "Mosaic" | Generic but branded |
| Port | 14242 | Standard default |
| Storage tier | local | No external deps |
| Hostname | localhost | Dev-first |
| CORS origin | http://localhost:3000 | Standard web UI port |
| Skills | recommended set | Curated by maintainers |
| Runtimes | auto-detected | No user input needed |
| Communication style | direct | Most popular choice |
| SOUL.md / USER.md / TOOLS.md | template defaults | Can customize later |
| Hooks | auto-install if Claude detected | Safe default |
### Flow
```
Quick Start selected
-> "Paste your LLM API key (Anthropic recommended):"
-> [auto-detect provider from key prefix: sk-ant-* = Anthropic, sk-* = OpenAI]
-> Apply all defaults
-> Run finalize (sync framework, write configs, link assets, sync skills)
-> Run gateway config (headless-style with defaults + provided key)
-> "Admin email:"
-> "Admin password:" (masked + confirm)
-> Run gateway bootstrap
-> Done
```
## 3. Provider-first flow
Provider configuration (currently buried in gateway-config stage as "ANTHROPIC_API_KEY")
moves to a dedicated top-level menu item and is the first question in Quick Start.
### Provider detection
The API key prefix determines the provider:
- `sk-ant-api03-*` -> Anthropic (Claude)
- `sk-*` -> OpenAI
- Empty/skipped -> no provider (gateway starts without LLM access)
### Storage
The provider key is stored in the gateway `.env` as `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`.
For Quick Start, this replaces the old interactive prompt in `collectAndWriteConfig`.
### Menu section: "Providers"
In the drill-down menu, "Providers" lets users:
1. Enter/change their API key
2. See which provider was detected
3. Optionally configure a second provider
For v0.0.27, we support Anthropic and OpenAI keys only. The key is stored
in `WizardState` and written during finalize.
## 4. Intent intake + naming (deterministic fallback - Option B)
### Rationale
At install time, the LLM provider may not be configured yet (chicken-and-egg).
We use **Option B: deterministic advisor** for the install wizard.
### Flow (Agent Identity menu section)
```
1. "What will this agent primarily help you with?"
-> Select from presets:
- General purpose assistant
- Software development
- DevOps & infrastructure
- Research & analysis
- Content & writing
- Custom (free text description)
2. System proposes a thematic name based on selection:
- General purpose -> "Mosaic"
- Software development -> "Forge"
- DevOps & infrastructure -> "Sentinel"
- Research & analysis -> "Atlas"
- Content & writing -> "Muse"
- Custom -> "Mosaic" (default)
3. "Your agent will be named 'Forge'. Press Enter to accept or type a new name:"
-> User confirms or overrides
```
### Storage
- Agent name -> `WizardState.soul.agentName` -> written to SOUL.md
- Intent category -> `WizardState.agentIntent` (new field) -> written to `~/.config/mosaic/agent.json`
### Post-install LLM-powered intake (future)
A future `mosaic configure identity` command can use the configured LLM to:
- Accept free-text intent description
- Generate an expounded persona
- Propose a contextual name
This is explicitly out of scope for the install wizard.
## 5. Headless backward-compat
### Supported env vars (unchanged)
| Variable | Used by |
| -------------------------- | ---------------------------------------------- |
| `MOSAIC_ASSUME_YES=1` | Skip all prompts, use defaults + env overrides |
| `MOSAIC_ADMIN_NAME` | Gateway bootstrap |
| `MOSAIC_ADMIN_EMAIL` | Gateway bootstrap |
| `MOSAIC_ADMIN_PASSWORD` | Gateway bootstrap |
| `MOSAIC_GATEWAY_PORT` | Gateway config |
| `MOSAIC_HOSTNAME` | Gateway config (CORS derivation) |
| `MOSAIC_CORS_ORIGIN` | Gateway config (full override) |
| `MOSAIC_STORAGE_TIER` | Gateway config (local/team) |
| `MOSAIC_DATABASE_URL` | Gateway config (team tier) |
| `MOSAIC_VALKEY_URL` | Gateway config (team tier) |
| `MOSAIC_ANTHROPIC_API_KEY` | Provider config |
### New env vars
| Variable | Purpose |
| --------------------- | ----------------------------------------- |
| `MOSAIC_AGENT_NAME` | Override agent name in headless mode |
| `MOSAIC_AGENT_INTENT` | Override intent category in headless mode |
### `tools/install.sh --yes`
The install script sets `MOSAIC_ASSUME_YES=1` and passes through env vars.
No changes needed to the script itself. The new wizard detects headless mode
at the top of `runWizard` and runs a linear path identical to the old flow.
## 6. Explicit non-goals
- **No GUI** — this is a terminal wizard only
- **No multi-user install** — single-user, single-machine
- **No registry changes** — npm publish flow is unchanged
- **No LLM calls during install** — deterministic fallback only
- **No new dependencies** — uses existing @clack/prompts and picocolors
- **No changes to gateway API** — only the wizard orchestration changes
- **No changes to tools/install.sh** — headless compat maintained via env vars
## 7. Implementation plan
### Files to modify
1. `packages/mosaic/src/types.ts` — add `MenuSection`, `AgentIntent`, `completedSections`, `agentIntent`, `providerKey`, `providerType` to WizardState
2. `packages/mosaic/src/wizard.ts` — replace linear flow with menu loop
3. `packages/mosaic/src/stages/mode-select.ts` — becomes the main menu
4. `packages/mosaic/src/stages/provider-setup.ts` — new: provider key collection
5. `packages/mosaic/src/stages/agent-intent.ts` — new: intent intake + naming
6. `packages/mosaic/src/stages/menu-gateway.ts` — new: gateway sub-menu wrapper
7. `packages/mosaic/src/stages/quick-start.ts` — new: quick start linear path
8. `packages/mosaic/src/constants.ts` — add intent presets and name mappings
9. `packages/mosaic/package.json` — version bump 0.0.26 -> 0.0.27
### Files to add (tests)
1. `packages/mosaic/src/stages/wizard-menu.spec.ts` — menu navigation tests
2. `packages/mosaic/src/stages/quick-start.spec.ts` — quick start path tests
3. `packages/mosaic/src/stages/agent-intent.spec.ts` — intent + naming tests
4. `packages/mosaic/src/stages/provider-setup.spec.ts` — provider detection tests
### Migration strategy
The existing stage functions remain intact. The menu system wraps them —
each menu item calls the appropriate stage function(s). The linear headless
path calls them in the same order as before.

View File

@@ -73,6 +73,27 @@ Spawn a worker instead. No exceptions. No "quick fixes."
- Wait for at least one worker to complete before spawning more
- This optimizes token usage and reduces context pressure
## File Ownership & Partitioning (Hard Rule for Parallel Workers)
When dispatching parallel workers, the orchestrator MUST assign **non-overlapping file scopes** to each worker. File collisions between parallel workers cause merge conflicts, lost edits, and wasted tokens.
**Rules:**
1. **Exclusive file ownership.** Each file may be assigned to at most one active worker. The orchestrator records ownership in the worker dispatch (prompt or task definition).
2. **Partition by directory or module.** Prefer assigning entire directories/modules to one worker rather than splitting files within a directory across workers.
3. **Shared files are serialized.** If two tasks must modify the same file (e.g., a shared types file, a barrel export), they MUST run sequentially — never in parallel. Mark the second task with `depends_on` pointing to the first.
4. **Test files follow source ownership.** If Worker A owns `src/auth/login.ts`, Worker A also owns `src/auth/__tests__/login.test.ts`. Do not split source and test across workers.
5. **Config files are orchestrator-reserved.** Files like `package.json`, `tsconfig.json`, and CI config are owned by the orchestrator and modified only between worker cycles, never during parallel execution.
6. **Document ownership in dispatch.** When spawning a worker, include an explicit `Files:` section listing owned paths/globs. Example:
```
Files (exclusive — do not touch files outside this scope):
- apps/web/src/components/auth/**
- apps/web/src/lib/auth.ts
```
7. **Conflict recovery.** If a worker edits a file outside its scope, the orchestrator MUST flag the violation, assess the diff, and either revert the out-of-scope change or re-run the affected worker with the corrected file.
## Delegation Mode Selection
Choose one delegation mode at session start:

View File

@@ -3,7 +3,7 @@
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/agent"
},
"main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/auth"
},
"type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.3",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/brain"
},
"main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/config"
},
"type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/coord"
},
"main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.3",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/db"
},
"type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/design-tokens"
},
"type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.3",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/forge"
},
"type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.3",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/log"
},
"type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.3",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/macp"
},
"type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.4",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/memory"
},
"type": "module",

View File

@@ -17,6 +17,7 @@ import { runWizard } from '../../src/wizard.js';
describe('Full Wizard (headless)', () => {
let tmpDir: string;
const repoRoot = join(import.meta.dirname, '..', '..');
const originalEnv = { ...process.env };
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-'));
@@ -32,12 +33,16 @@ describe('Full Wizard (headless)', () => {
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
process.env = { ...originalEnv };
});
it('quick start produces valid SOUL.md', async () => {
// The headless path reads agent name from MOSAIC_AGENT_NAME env var
// (via agentIntentStage) rather than prompting interactively.
process.env['MOSAIC_AGENT_NAME'] = 'TestBot';
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'They/Them',
@@ -62,9 +67,10 @@ describe('Full Wizard (headless)', () => {
});
it('quick start produces valid USER.md', async () => {
process.env['MOSAIC_AGENT_NAME'] = 'TestBot';
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'He/Him',

View File

@@ -151,11 +151,68 @@ When delegating work to subagents, you MUST select the cheapest model capable of
**Runtime-specific syntax**: See the runtime reference for how to specify model tier when spawning subagents (e.g., Claude Code Task tool `model` parameter).
## Superpowers Enforcement (Hard Rule)
Mosaic provides capabilities beyond basic code editing: **skills**, **hooks**, **MCP tools**, and **plugins**. These are not optional extras — they are force multipliers that agents MUST actively use when applicable. Under-utilization of superpowers is a framework violation.
### Skills
Skills are domain-specific instruction sets in `~/.config/mosaic/skills/` that encode best practices, patterns, and guardrails. They are loaded into agents via the runtime's skill mechanism (e.g., Claude Code slash commands, Pi `--skill` flag).
**Rules:**
1. Before starting implementation, scan available skills (`ls ~/.config/mosaic/skills/`) and load any that match the task domain.
2. When a skill exists for the technology being used (e.g., `nestjs-best-practices` for NestJS work), you MUST load it.
3. When spawning workers, include skill loading in the kickstart prompt.
4. If you complete a task without loading a relevant available skill, that is a quality gap.
### Hooks
Hooks provide automated quality gates (lint, format, typecheck) that fire on file edits. They are configured in the runtime settings and run automatically.
**Rules:**
1. Do NOT bypass or suppress hook output. If a hook reports errors, fix them before proceeding.
2. Hook failures are immediate feedback — treat them like failing tests.
3. If a hook is consistently failing on valid code, report it as a framework issue rather than working around it.
### MCP Tools
MCP servers extend agent capabilities with external integrations (sequential-thinking, web search, memory, browser automation, etc.). Available MCP tools are listed at session start.
**Rules:**
1. **sequential-thinking** is REQUIRED for planning, architecture, and multi-step reasoning. Use it — do not skip structured thinking for complex decisions.
2. **OpenBrain** (`capture`, `search`, `recent`) is the cross-agent memory layer. Capture discoveries and search for prior context at session start.
3. When a task involves web research, browser testing, or external data, use the available MCP tools (web-search, chrome-devtools, web-reader) rather than asking the user to look things up.
4. Check available MCP tools at session start and use them proactively throughout the session.
### Plugins (Runtime-Specific)
Runtime plugins (e.g., Claude Code's `feature-dev`, `pr-review-toolkit`, `code-review`) provide specialized agent capabilities like code review, architecture analysis, and test coverage analysis.
**Rules:**
1. After completing a significant code change, use code review plugins proactively — do not wait for the user to ask.
2. Before creating a PR, use PR review plugins to catch issues early.
3. When designing architecture, use planning/architecture plugins for structured analysis.
### Self-Evolution
The Mosaic framework should improve over time based on usage patterns:
1. When you discover a recurring pattern that should be codified, capture it to OpenBrain with `type: "framework-improvement"`.
2. When a hook, skill, or tool is missing for a common task, capture the gap to OpenBrain with `type: "tooling-gap"`.
3. When a framework rule causes friction without adding value, capture the observation to OpenBrain with `type: "framework-friction"`.
These captures feed the framework's continuous improvement cycle.
## Skills Policy
- Use only the minimum required skills for the active task.
- Load skills that match the active task domain before starting implementation.
- Do not load unrelated skills.
- Follow skill trigger rules from the active runtime instruction layer.
- Actively check `~/.config/mosaic/skills/` for applicable skills rather than passively waiting for them to be mentioned.
## Session Closure Requirement

View File

@@ -4,14 +4,20 @@ Universal agent standards layer for Claude Code, Codex, OpenCode, and Pi.
One config, every runtime, same standards.
> **This is the framework component of [mosaic-stack](https://git.mosaicstack.dev/mosaic/mosaic-stack).** 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.
> **This is the framework component of [mosaic-stack](https://git.mosaicstack.dev/mosaicstack/stack).** 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
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
curl -fsSL https://mosaicstack.dev/install.sh | bash
```
Or use the direct URL:
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
```
### Windows (PowerShell)
@@ -23,8 +29,8 @@ bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/mai
### From Source (any platform)
```bash
git clone git@git.mosaicstack.dev:mosaic/mosaic-stack.git ~/src/mosaic-stack
cd ~/src/mosaic-stack && bash tools/install.sh
git clone git@git.mosaicstack.dev:mosaicstack/stack.git ~/src/stack
cd ~/src/stack && bash tools/install.sh
```
The installer:
@@ -145,13 +151,19 @@ mosaic upgrade check # Check upgrade status (no changes)
Run the installer again — it handles upgrades automatically:
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
curl -fsSL https://mosaicstack.dev/install.sh | bash
```
Or use the direct URL:
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
```
Or from a local checkout:
```bash
cd ~/src/mosaic-stack && git pull && bash tools/install.sh
cd ~/src/stack && git pull && bash tools/install.sh
```
The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by default.

View File

@@ -19,8 +19,9 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# Files preserved across upgrades (never overwritten)
PRESERVE_PATHS=("SOUL.md" "USER.md" "TOOLS.md" "memory" "sources")
# Files/dirs preserved across upgrades (never overwritten).
# User-created content in these paths survives rsync --delete.
PRESERVE_PATHS=("AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials")
# Current framework schema version — bump this when the layout changes.
# The migration system uses this to run upgrade steps.
@@ -217,8 +218,22 @@ fi
sync_framework
# Ensure memory directory exists
# Ensure persistent directories exist
mkdir -p "$TARGET_DIR/memory"
mkdir -p "$TARGET_DIR/credentials"
# Seed defaults — copy from defaults/ to framework root if not already present.
# These are user-editable files that ship with sensible defaults but should
# never be overwritten once the user has customized them.
DEFAULTS_DIR="$TARGET_DIR/defaults"
if [[ -d "$DEFAULTS_DIR" ]]; then
for default_file in AGENTS.md STANDARDS.md; do
if [[ -f "$DEFAULTS_DIR/$default_file" ]] && [[ ! -f "$TARGET_DIR/$default_file" ]]; then
cp "$DEFAULTS_DIR/$default_file" "$TARGET_DIR/$default_file"
ok "Seeded $default_file from defaults"
fi
done
fi
# Ensure tool scripts are executable
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true

View File

@@ -102,3 +102,30 @@ claude mcp add --scope user <name> -- npx -y <package>
`--scope local` = default, local-only (not committed).
Do NOT add `mcpServers` to `~/.claude/settings.json` — that key is ignored for MCP loading.
## Required Claude Code Settings (Enforced by Launcher)
The `mosaic claude` launcher validates that `~/.claude/settings.json` contains the required Mosaic configuration. Missing or outdated settings trigger a warning at launch.
**Required hooks:**
| Event | Matcher | Script | Purpose |
| ----------- | ------------------------ | ------------------------- | ---------------------------------------------- |
| PreToolUse | `Write\|Edit\|MultiEdit` | `prevent-memory-write.sh` | Block writes to `~/.claude/projects/*/memory/` |
| PostToolUse | `Edit\|MultiEdit\|Write` | `qa-hook-stdin.sh` | QA report generation after code edits |
| PostToolUse | `Edit\|MultiEdit\|Write` | `typecheck-hook.sh` | Inline TypeScript type checking |
**Required plugins:**
| Plugin | Purpose |
| ------------------- | -------------------------------------------------------------------------------------------------------- |
| `feature-dev` | Subagent architecture: code-reviewer, code-architect, code-explorer |
| `pr-review-toolkit` | PR review: code-simplifier, comment-analyzer, test-analyzer, silent-failure-hunter, type-design-analyzer |
| `code-review` | Standalone code review capabilities |
**Required settings:**
- `enableAllMcpTools: true` — Allow all configured MCP tools without per-tool approval
- `model: "opus"` — Default to opus for orchestrator-level sessions (workers use tiered models via Task tool)
If `mosaic claude` detects missing hooks or plugins, it will print a warning with the exact settings to add. The session will still launch — enforcement is advisory, not blocking — but agents operating without these settings are running degraded.

View File

@@ -23,6 +23,16 @@
"timeout": 60
}
]
},
{
"matcher": "Edit|MultiEdit|Write",
"hooks": [
{
"type": "command",
"command": "~/.config/mosaic/tools/qa/typecheck-hook.sh",
"timeout": 30
}
]
}
]
},

View File

@@ -0,0 +1,63 @@
#!/bin/bash
# Lightweight PostToolUse typecheck hook for TypeScript files.
# Runs tsc --noEmit on the nearest tsconfig after TS/TSX edits.
# Returns non-zero with diagnostic output so the agent sees type errors immediately.
# Location: ~/.config/mosaic/tools/qa/typecheck-hook.sh
set -eo pipefail
# Read JSON from stdin (Claude Code PostToolUse payload)
JSON_INPUT=$(cat)
# Extract file path
if command -v jq &>/dev/null; then
FILE_PATH=$(echo "$JSON_INPUT" | jq -r '.tool_input.file_path // .tool_response.filePath // .file_path // empty' 2>/dev/null || echo "")
else
FILE_PATH=$(echo "$JSON_INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/' | head -1)
fi
# Only check TypeScript files
if ! [[ "$FILE_PATH" =~ \.(ts|tsx)$ ]]; then
exit 0
fi
# Must be a real file
if [ ! -f "$FILE_PATH" ]; then
exit 0
fi
# Find nearest tsconfig.json by walking up from the file
DIR=$(dirname "$FILE_PATH")
TSCONFIG=""
while [ "$DIR" != "/" ] && [ "$DIR" != "." ]; do
if [ -f "$DIR/tsconfig.json" ]; then
TSCONFIG="$DIR/tsconfig.json"
break
fi
DIR=$(dirname "$DIR")
done
if [ -z "$TSCONFIG" ]; then
# No tsconfig found — skip silently
exit 0
fi
# Run tsc --noEmit from the tsconfig directory
# Use --pretty for readable output, limit to 10 errors to keep output short
TSCONFIG_DIR=$(dirname "$TSCONFIG")
cd "$TSCONFIG_DIR"
# Run typecheck — capture output and exit code
OUTPUT=$(npx tsc --noEmit --pretty --maxNodeModuleJsDepth 0 2>&1) || STATUS=$?
if [ "${STATUS:-0}" -ne 0 ]; then
# Filter output to only show errors related to the edited file (if possible)
BASENAME=$(basename "$FILE_PATH")
RELEVANT=$(echo "$OUTPUT" | grep -A2 "$BASENAME" 2>/dev/null || echo "$OUTPUT" | head -20)
echo "TypeScript type errors detected after editing $FILE_PATH:"
echo "$RELEVANT"
exit 1
fi
exit 0

View File

@@ -1,9 +1,9 @@
{
"name": "@mosaicstack/mosaic",
"version": "0.0.26",
"version": "0.0.29",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/mosaic"
},
"description": "Mosaic agent framework — installation wizard and meta package",

View File

@@ -135,15 +135,11 @@ program
// No valid session — prompt for credentials
if (!session) {
const readline = await import('node:readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> =>
new Promise((resolve) => rl.question(q, resolve));
const { promptLine, promptSecret } = await import('./commands/gateway/login.js');
console.log(`Sign in to ${opts.gateway}`);
const email = await ask('Email: ');
const password = await ask('Password: ');
rl.close();
const email = await promptLine('Email: ');
const password = await promptSecret('Password: ');
try {
const auth = await signIn(opts.gateway, email, password);

View File

@@ -78,6 +78,82 @@ function checkSoul(): void {
}
}
// ─── Claude settings validation ─────────────────────────────────────────────
interface SettingsAudit {
warnings: string[];
}
function auditClaudeSettings(): SettingsAudit {
const warnings: string[] = [];
const settingsPath = join(homedir(), '.claude', 'settings.json');
const settings = readJson(settingsPath);
if (!settings) {
warnings.push('~/.claude/settings.json not found — hooks and plugins will be missing');
return { warnings };
}
// Check required hooks
const hooks = settings['hooks'] as Record<string, unknown[]> | undefined;
const requiredPreToolUse = ['prevent-memory-write.sh'];
const requiredPostToolUse = ['qa-hook-stdin.sh', 'typecheck-hook.sh'];
const preHooks = (hooks?.['PreToolUse'] ?? []) as Array<Record<string, unknown>>;
const postHooks = (hooks?.['PostToolUse'] ?? []) as Array<Record<string, unknown>>;
const preCommands = preHooks.flatMap((h) => {
const inner = (h['hooks'] ?? []) as Array<Record<string, unknown>>;
return inner.map((ih) => String(ih['command'] ?? ''));
});
const postCommands = postHooks.flatMap((h) => {
const inner = (h['hooks'] ?? []) as Array<Record<string, unknown>>;
return inner.map((ih) => String(ih['command'] ?? ''));
});
for (const script of requiredPreToolUse) {
if (!preCommands.some((c) => c.includes(script))) {
warnings.push(`Missing PreToolUse hook: ${script}`);
}
}
for (const script of requiredPostToolUse) {
if (!postCommands.some((c) => c.includes(script))) {
warnings.push(`Missing PostToolUse hook: ${script}`);
}
}
// Check required plugins
const plugins = (settings['enabledPlugins'] ?? {}) as Record<string, boolean>;
const requiredPlugins = ['feature-dev', 'pr-review-toolkit', 'code-review'];
for (const plugin of requiredPlugins) {
const found = Object.keys(plugins).some((k) => k.startsWith(plugin) && plugins[k]);
if (!found) {
warnings.push(`Missing plugin: ${plugin}`);
}
}
// Check enableAllMcpTools
if (!settings['enableAllMcpTools']) {
warnings.push('enableAllMcpTools is not true — MCP tools may require per-tool approval');
}
return { warnings };
}
function printSettingsWarnings(audit: SettingsAudit): void {
if (audit.warnings.length === 0) return;
console.log('\n[mosaic] Claude Code settings audit:');
for (const w of audit.warnings) {
console.log(`${w}`);
}
console.log(
'[mosaic] Run: mosaic doctor — or see ~/.config/mosaic/runtime/claude/RUNTIME.md for required settings.\n',
);
}
function checkSequentialThinking(runtime: string): void {
const checker = fwScript('mosaic-ensure-sequential-thinking');
if (!existsSync(checker)) return; // Skip if checker doesn't exist
@@ -407,6 +483,10 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
switch (runtime) {
case 'claude': {
// Audit Claude Code settings and warn about missing hooks/plugins
const settingsAudit = auditClaudeSettings();
printSettingsWarnings(settingsAudit);
const prompt = buildRuntimePrompt('claude');
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
cliArgs.push('--append-system-prompt', prompt);

View File

@@ -26,6 +26,53 @@ export const DEFAULTS = {
| (add your git providers here) | | | |`,
};
/** Preset intent categories with display labels and suggested agent names. */
export const INTENT_PRESETS: Record<
string,
{ label: string; hint: string; suggestedName: string }
> = {
general: {
label: 'General purpose assistant',
hint: 'Versatile helper for any task',
suggestedName: 'Mosaic',
},
'software-dev': {
label: 'Software development',
hint: 'Coding, debugging, architecture',
suggestedName: 'Forge',
},
devops: {
label: 'DevOps & infrastructure',
hint: 'CI/CD, containers, monitoring',
suggestedName: 'Sentinel',
},
research: {
label: 'Research & analysis',
hint: 'Data analysis, literature review',
suggestedName: 'Atlas',
},
content: {
label: 'Content & writing',
hint: 'Documentation, copywriting, editing',
suggestedName: 'Muse',
},
custom: {
label: 'Custom',
hint: 'Describe your own use case',
suggestedName: 'Mosaic',
},
};
/**
* Detect LLM provider type from an API key prefix.
*/
export function detectProviderType(key: string): 'anthropic' | 'openai' | 'none' {
if (!key) return 'none';
if (key.startsWith('sk-ant-')) return 'anthropic';
if (key.startsWith('sk-')) return 'openai';
return 'none';
}
export const RECOMMENDED_SKILLS = new Set([
'brainstorming',
'code-review-excellence',

View File

@@ -0,0 +1,129 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import type { WizardState } from '../types.js';
import { agentIntentStage } from './agent-intent.js';
function buildPrompter(overrides: Partial<Record<string, unknown>> = {}) {
return {
intro: vi.fn(),
outro: vi.fn(),
note: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
text: vi.fn().mockResolvedValue('Mosaic'),
confirm: vi.fn().mockResolvedValue(false),
select: vi.fn().mockResolvedValue('general'),
multiselect: vi.fn(),
groupMultiselect: vi.fn(),
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
separator: vi.fn(),
...overrides,
};
}
function makeState(): WizardState {
return {
mosaicHome: '/tmp/mosaic',
sourceDir: '/tmp/mosaic',
mode: 'quick',
installAction: 'fresh',
soul: {},
user: {},
tools: {},
runtimes: { detected: [], mcpConfigured: false },
selectedSkills: [],
};
}
describe('agentIntentStage', () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
it('uses default intent and name in headless mode', async () => {
process.env['MOSAIC_ASSUME_YES'] = '1';
delete process.env['MOSAIC_AGENT_INTENT'];
delete process.env['MOSAIC_AGENT_NAME'];
const state = makeState();
const p = buildPrompter();
await agentIntentStage(p, state);
expect(state.agentIntent).toBe('general');
expect(state.soul.agentName).toBe('Mosaic');
});
it('reads intent from MOSAIC_AGENT_INTENT env var', async () => {
process.env['MOSAIC_ASSUME_YES'] = '1';
process.env['MOSAIC_AGENT_INTENT'] = 'software-dev';
delete process.env['MOSAIC_AGENT_NAME'];
const state = makeState();
const p = buildPrompter();
await agentIntentStage(p, state);
expect(state.agentIntent).toBe('software-dev');
expect(state.soul.agentName).toBe('Forge');
});
it('honors MOSAIC_AGENT_NAME env var override', async () => {
process.env['MOSAIC_ASSUME_YES'] = '1';
process.env['MOSAIC_AGENT_INTENT'] = 'devops';
process.env['MOSAIC_AGENT_NAME'] = 'MyBot';
const state = makeState();
const p = buildPrompter();
await agentIntentStage(p, state);
expect(state.agentIntent).toBe('devops');
expect(state.soul.agentName).toBe('MyBot');
});
it('falls back to general for unknown intent values', async () => {
process.env['MOSAIC_ASSUME_YES'] = '1';
process.env['MOSAIC_AGENT_INTENT'] = 'nonexistent';
delete process.env['MOSAIC_AGENT_NAME'];
const state = makeState();
const p = buildPrompter();
await agentIntentStage(p, state);
expect(state.agentIntent).toBe('general');
expect(state.soul.agentName).toBe('Mosaic');
});
it('prompts for intent and name in interactive mode', async () => {
delete process.env['MOSAIC_ASSUME_YES'];
const origIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
const state = makeState();
const p = buildPrompter({
select: vi.fn().mockResolvedValue('research'),
text: vi.fn().mockResolvedValue('Atlas'),
});
await agentIntentStage(p, state);
expect(state.agentIntent).toBe('research');
expect(state.soul.agentName).toBe('Atlas');
expect(p.select).toHaveBeenCalled();
expect(p.text).toHaveBeenCalled();
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
});
it('maps content intent to Muse suggested name', async () => {
process.env['MOSAIC_ASSUME_YES'] = '1';
process.env['MOSAIC_AGENT_INTENT'] = 'content';
delete process.env['MOSAIC_AGENT_NAME'];
const state = makeState();
const p = buildPrompter();
await agentIntentStage(p, state);
expect(state.agentIntent).toBe('content');
expect(state.soul.agentName).toBe('Muse');
});
});

View File

@@ -0,0 +1,64 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { AgentIntent, WizardState } from '../types.js';
import { INTENT_PRESETS } from '../constants.js';
/**
* Agent intent + naming stage — deterministic (no LLM required).
*
* The user picks an intent category from presets, the system proposes a
* thematic name, and the user confirms or overrides it.
*
* In headless mode, reads from `MOSAIC_AGENT_INTENT` and `MOSAIC_AGENT_NAME`.
*/
export async function agentIntentStage(p: WizardPrompter, state: WizardState): Promise<void> {
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
if (isHeadless) {
const intentEnv = process.env['MOSAIC_AGENT_INTENT'] ?? 'general';
const nameEnv = process.env['MOSAIC_AGENT_NAME'];
const preset = INTENT_PRESETS[intentEnv] ?? INTENT_PRESETS['general']!;
state.agentIntent ??= (intentEnv in INTENT_PRESETS ? intentEnv : 'general') as AgentIntent;
// Respect existing agentName (e.g. from CLI overrides) — only set from
// env/preset if not already populated.
state.soul.agentName ??= nameEnv ?? preset.suggestedName;
return;
}
p.separator();
p.note(
'Tell us what this agent will primarily help you with.\n' +
"We'll suggest a name based on your choice — you can always change it.",
'Agent Identity',
);
const intentOptions = Object.entries(INTENT_PRESETS).map(([value, info]) => ({
value: value as AgentIntent,
label: info.label,
hint: info.hint,
}));
const intent = await p.select<AgentIntent>({
message: 'What will this agent primarily help you with?',
options: intentOptions,
initialValue: 'general' as AgentIntent,
});
state.agentIntent = intent;
const preset = INTENT_PRESETS[intent];
const suggestedName = preset?.suggestedName ?? 'Mosaic';
const name = await p.text({
message: `Your agent will be named "${suggestedName}". Press Enter to accept or type a new name`,
initialValue: suggestedName,
defaultValue: suggestedName,
validate: (v) => {
if (v.length === 0) return 'Name cannot be empty';
if (v.length > 50) return 'Name must be under 50 characters';
return undefined;
},
});
state.soul.agentName = name;
p.log(`Agent name set to: ${name}`);
}

View File

@@ -126,6 +126,14 @@ export interface GatewayConfigStageOptions {
portOverride?: number;
/** Skip the `npm install -g @mosaicstack/gateway` step (local build / tests). */
skipInstall?: boolean;
/**
* Pre-collected provider API key (from the provider-setup stage or Quick
* Start path). When set, the gateway-config stage will skip the interactive
* API key prompt and use this value directly.
*/
providerKey?: string;
/** Provider type detected from the key prefix. */
providerType?: 'anthropic' | 'openai' | 'none';
}
export interface GatewayConfigStageResult {
@@ -314,6 +322,8 @@ export async function gatewayConfigStage(
envFile: ENV_FILE,
mosaicConfigFile: MOSAIC_CONFIG_FILE,
gatewayHome: GATEWAY_HOME,
providerKey: opts.providerKey,
providerType: opts.providerType,
});
} catch (err) {
if (err instanceof GatewayConfigValidationError) {
@@ -389,6 +399,10 @@ interface CollectOptions {
envFile: string;
mosaicConfigFile: string;
gatewayHome: string;
/** Pre-collected API key — skips the interactive prompt when set. */
providerKey?: string;
/** Provider type — determines the env var name for the key. */
providerType?: 'anthropic' | 'openai' | 'none';
}
/** Raised by the config stage when headless env validation fails. */
@@ -466,10 +480,15 @@ async function collectAndWriteConfig(
});
}
if (opts.providerKey) {
anthropicKey = opts.providerKey;
p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`);
} else {
anthropicKey = await p.text({
message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',
defaultValue: '',
});
}
hostname = await p.text({
message: 'Web UI hostname (for browser access)',
@@ -508,8 +527,12 @@ async function collectAndWriteConfig(
}
if (anthropicKey) {
if (opts.providerType === 'openai') {
envLines.push(`OPENAI_API_KEY=${anthropicKey}`);
} else {
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
}
}
writeFileSync(opts.envFile, envLines.join('\n') + '\n', { mode: 0o600 });
p.log(`Config written to ${opts.envFile}`);

View File

@@ -0,0 +1,118 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import type { WizardState } from '../types.js';
import { providerSetupStage } from './provider-setup.js';
function buildPrompter(overrides: Partial<Record<string, unknown>> = {}) {
return {
intro: vi.fn(),
outro: vi.fn(),
note: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
text: vi.fn().mockResolvedValue(''),
confirm: vi.fn().mockResolvedValue(false),
select: vi.fn().mockResolvedValue('general'),
multiselect: vi.fn(),
groupMultiselect: vi.fn(),
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
separator: vi.fn(),
...overrides,
};
}
function makeState(): WizardState {
return {
mosaicHome: '/tmp/mosaic',
sourceDir: '/tmp/mosaic',
mode: 'quick',
installAction: 'fresh',
soul: {},
user: {},
tools: {},
runtimes: { detected: [], mcpConfigured: false },
selectedSkills: [],
};
}
describe('providerSetupStage', () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
it('detects Anthropic key from prefix in headless mode', async () => {
process.env['MOSAIC_ASSUME_YES'] = '1';
process.env['MOSAIC_ANTHROPIC_API_KEY'] = 'sk-ant-api03-test123';
const state = makeState();
const p = buildPrompter();
await providerSetupStage(p, state);
expect(state.providerKey).toBe('sk-ant-api03-test123');
expect(state.providerType).toBe('anthropic');
});
it('detects OpenAI key from prefix in headless mode', async () => {
process.env['MOSAIC_ASSUME_YES'] = '1';
process.env['MOSAIC_OPENAI_API_KEY'] = 'sk-proj-test123';
const state = makeState();
const p = buildPrompter();
await providerSetupStage(p, state);
expect(state.providerKey).toBe('sk-proj-test123');
expect(state.providerType).toBe('openai');
});
it('sets provider type to none when no key is provided in headless mode', async () => {
process.env['MOSAIC_ASSUME_YES'] = '1';
delete process.env['MOSAIC_ANTHROPIC_API_KEY'];
delete process.env['MOSAIC_OPENAI_API_KEY'];
const state = makeState();
const p = buildPrompter();
await providerSetupStage(p, state);
expect(state.providerKey).toBeUndefined();
expect(state.providerType).toBe('none');
});
it('prompts for key in interactive mode', async () => {
delete process.env['MOSAIC_ASSUME_YES'];
// Simulate a TTY
const origIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
const state = makeState();
const p = buildPrompter({
text: vi.fn().mockResolvedValue('sk-ant-api03-interactive'),
});
await providerSetupStage(p, state);
expect(p.text).toHaveBeenCalled();
expect(state.providerKey).toBe('sk-ant-api03-interactive');
expect(state.providerType).toBe('anthropic');
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
});
it('handles empty key in interactive mode', async () => {
delete process.env['MOSAIC_ASSUME_YES'];
const origIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
const state = makeState();
const p = buildPrompter({
text: vi.fn().mockResolvedValue(''),
});
await providerSetupStage(p, state);
expect(state.providerType).toBe('none');
expect(state.providerKey).toBeUndefined();
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
});
});

View File

@@ -0,0 +1,54 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { WizardState } from '../types.js';
import { detectProviderType } from '../constants.js';
/**
* Provider setup stage — collects the user's LLM API key and detects the
* provider type from the key prefix.
*
* In headless mode, reads from `MOSAIC_ANTHROPIC_API_KEY` or `MOSAIC_OPENAI_API_KEY`.
*/
export async function providerSetupStage(p: WizardPrompter, state: WizardState): Promise<void> {
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
if (isHeadless) {
const anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? '';
const openaiKey = process.env['MOSAIC_OPENAI_API_KEY'] ?? '';
const key = anthropicKey || openaiKey;
state.providerKey = key || undefined;
state.providerType = detectProviderType(key);
return;
}
p.separator();
p.note(
'Configure your LLM provider so the agent has a brain.\n' +
'Anthropic (Claude) and OpenAI are supported.\n' +
'You can skip this and add a key later via `mosaic configure`.',
'LLM Provider',
);
const key = await p.text({
message: 'API key (paste your Anthropic or OpenAI key, or press Enter to skip)',
defaultValue: '',
placeholder: 'sk-ant-api03-... or sk-...',
});
if (key) {
const provider = detectProviderType(key);
state.providerKey = key;
state.providerType = provider;
if (provider === 'anthropic') {
p.log('Detected provider: Anthropic (Claude)');
} else if (provider === 'openai') {
p.log('Detected provider: OpenAI');
} else {
p.log('Provider auto-detection failed. Key will be stored as ANTHROPIC_API_KEY.');
state.providerType = 'anthropic';
}
} else {
state.providerType = 'none';
p.log('No API key provided. You can add one later with `mosaic configure`.');
}
}

View File

@@ -0,0 +1,98 @@
import type { WizardPrompter } from '../prompter/interface.js';
import type { ConfigService } from '../config/config-service.js';
import type { WizardState } from '../types.js';
import { DEFAULTS } from '../constants.js';
import { providerSetupStage } from './provider-setup.js';
import { runtimeSetupStage } from './runtime-setup.js';
import { hooksPreviewStage } from './hooks-preview.js';
import { skillsSelectStage } from './skills-select.js';
import { finalizeStage } from './finalize.js';
import { gatewayConfigStage } from './gateway-config.js';
import { gatewayBootstrapStage } from './gateway-bootstrap.js';
export interface QuickStartOptions {
skipGateway?: boolean;
gatewayHost?: string;
gatewayPort?: number;
gatewayPortOverride?: number;
skipGatewayNpmInstall?: boolean;
}
/**
* Quick Start path — minimal questions to get a working agent.
*
* 1. Provider API key
* 2. Admin email + password (via gateway bootstrap)
* 3. Everything else uses defaults.
*
* Target: under 90 seconds for a returning user.
*/
export async function quickStartPath(
prompter: WizardPrompter,
state: WizardState,
configService: ConfigService,
options: QuickStartOptions,
): Promise<void> {
state.mode = 'quick';
// 1. Provider setup (first question)
await providerSetupStage(prompter, state);
// Apply sensible defaults for everything else
state.soul.agentName ??= 'Mosaic';
state.soul.roleDescription ??= DEFAULTS.roleDescription;
state.soul.communicationStyle ??= 'direct';
state.user.background = DEFAULTS.background;
state.user.accessibilitySection = DEFAULTS.accessibilitySection;
state.user.personalBoundaries = DEFAULTS.personalBoundaries;
state.tools.gitProviders = [];
state.tools.credentialsLocation = DEFAULTS.credentialsLocation;
state.tools.customToolsSection = DEFAULTS.customToolsSection;
// Runtime detection (auto, no user input in quick mode)
await runtimeSetupStage(prompter, state);
// Hooks (auto-accept in quick mode for Claude)
await hooksPreviewStage(prompter, state);
// Skills (recommended set, no user input in quick mode)
await skillsSelectStage(prompter, state);
// Finalize (writes configs, links runtime assets, syncs skills)
await finalizeStage(prompter, state, configService);
// Gateway config + bootstrap
if (!options.skipGateway) {
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
try {
const configResult = await gatewayConfigStage(prompter, state, {
host: options.gatewayHost ?? 'localhost',
defaultPort: options.gatewayPort ?? 14242,
portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall,
providerKey: state.providerKey,
providerType: state.providerType ?? 'none',
});
if (!configResult.ready || !configResult.host || !configResult.port) {
if (headlessRun) {
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
process.exit(1);
}
} else {
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host,
port: configResult.port,
});
if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1);
}
}
} catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
}
}

View File

@@ -0,0 +1,118 @@
import { describe, it, expect, afterEach } from 'vitest';
import type { MenuSection } from '../types.js';
import { detectProviderType, INTENT_PRESETS } from '../constants.js';
/**
* Tests for the drill-down menu system and its supporting utilities.
*
* The menu loop itself is in wizard.ts and is hard to unit test in isolation
* because it orchestrates many async stages. These tests verify the building
* blocks: provider detection, intent presets, and the WizardState shape.
*/
describe('detectProviderType', () => {
it('detects Anthropic from sk-ant- prefix', () => {
expect(detectProviderType('sk-ant-api03-abc123')).toBe('anthropic');
});
it('detects OpenAI from sk- prefix', () => {
expect(detectProviderType('sk-proj-abc123')).toBe('openai');
});
it('returns none for empty string', () => {
expect(detectProviderType('')).toBe('none');
});
it('returns none for unrecognized prefix', () => {
expect(detectProviderType('gsk_abc123')).toBe('none');
});
});
describe('INTENT_PRESETS', () => {
it('has all expected intent categories', () => {
expect(Object.keys(INTENT_PRESETS)).toEqual(
expect.arrayContaining([
'general',
'software-dev',
'devops',
'research',
'content',
'custom',
]),
);
});
it('each preset has label, hint, and suggestedName', () => {
for (const [key, preset] of Object.entries(INTENT_PRESETS)) {
expect(preset.label, `${key}.label`).toBeTruthy();
expect(preset.hint, `${key}.hint`).toBeTruthy();
expect(preset.suggestedName, `${key}.suggestedName`).toBeTruthy();
}
});
it('maps software-dev to Forge', () => {
expect(INTENT_PRESETS['software-dev']?.suggestedName).toBe('Forge');
});
it('maps devops to Sentinel', () => {
expect(INTENT_PRESETS['devops']?.suggestedName).toBe('Sentinel');
});
});
describe('WizardState completedSections', () => {
it('tracks completed sections as a Set', () => {
const completed = new Set<MenuSection>();
completed.add('providers');
completed.add('identity');
expect(completed.has('providers')).toBe(true);
expect(completed.has('identity')).toBe(true);
expect(completed.has('skills')).toBe(false);
expect(completed.size).toBe(2);
});
});
describe('headless backward compat', () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
it('MOSAIC_ASSUME_YES=1 triggers headless path', () => {
process.env['MOSAIC_ASSUME_YES'] = '1';
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
expect(isHeadless).toBe(true);
});
it('non-TTY triggers headless path', () => {
delete process.env['MOSAIC_ASSUME_YES'];
// In test environments, process.stdin.isTTY is typically undefined (falsy)
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
expect(isHeadless).toBe(true);
});
it('all headless env vars are recognized', () => {
// This test documents the expected env vars for headless installs.
const headlessVars = [
'MOSAIC_ASSUME_YES',
'MOSAIC_ADMIN_NAME',
'MOSAIC_ADMIN_EMAIL',
'MOSAIC_ADMIN_PASSWORD',
'MOSAIC_GATEWAY_PORT',
'MOSAIC_HOSTNAME',
'MOSAIC_CORS_ORIGIN',
'MOSAIC_STORAGE_TIER',
'MOSAIC_DATABASE_URL',
'MOSAIC_VALKEY_URL',
'MOSAIC_ANTHROPIC_API_KEY',
'MOSAIC_AGENT_NAME',
'MOSAIC_AGENT_INTENT',
];
// Just verify none of them throw when accessed
for (const v of headlessVars) {
expect(() => process.env[v]).not.toThrow();
}
});
});

View File

@@ -3,6 +3,19 @@ export type InstallAction = 'fresh' | 'keep' | 'reconfigure' | 'reset';
export type CommunicationStyle = 'direct' | 'friendly' | 'formal';
export type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
export type MenuSection =
| 'quick-start'
| 'providers'
| 'identity'
| 'skills'
| 'gateway'
| 'advanced'
| 'finish';
export type AgentIntent = 'general' | 'software-dev' | 'devops' | 'research' | 'content' | 'custom';
export type ProviderType = 'anthropic' | 'openai' | 'none';
export interface SoulConfig {
agentName?: string;
roleDescription?: string;
@@ -86,4 +99,12 @@ export interface WizardState {
selectedSkills: string[];
hooks?: HooksState;
gateway?: GatewayState;
/** Tracks which menu sections have been completed in drill-down mode. */
completedSections?: Set<MenuSection>;
/** The user's chosen agent intent category. */
agentIntent?: AgentIntent;
/** The LLM provider API key entered during setup. */
providerKey?: string;
/** Detected provider type based on API key prefix. */
providerType?: ProviderType;
}

View File

@@ -1,9 +1,8 @@
import type { WizardPrompter } from './prompter/interface.js';
import type { ConfigService } from './config/config-service.js';
import type { WizardState } from './types.js';
import type { MenuSection, 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';
@@ -13,6 +12,10 @@ import { skillsSelectStage } from './stages/skills-select.js';
import { finalizeStage } from './stages/finalize.js';
import { gatewayConfigStage } from './stages/gateway-config.js';
import { gatewayBootstrapStage } from './stages/gateway-bootstrap.js';
import { providerSetupStage } from './stages/provider-setup.js';
import { agentIntentStage } from './stages/agent-intent.js';
import { quickStartPath } from './stages/quick-start.js';
import { DEFAULTS } from './constants.js';
export interface WizardOptions {
mosaicHome: string;
@@ -54,6 +57,7 @@ export async function runWizard(options: WizardOptions): Promise<void> {
tools: {},
runtimes: { detected: [], mcpConfigured: false },
selectedSkills: [],
completedSections: new Set<MenuSection>(),
};
// Apply CLI overrides (strip undefined values)
@@ -90,55 +94,304 @@ export async function runWizard(options: WizardOptions): Promise<void> {
// 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';
// ── Headless bypass ────────────────────────────────────────────────────────
// When MOSAIC_ASSUME_YES=1 or no TTY, run the linear headless path.
// This preserves full backward compatibility with tools/install.sh --yes.
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
if (headlessRun) {
await runHeadlessPath(prompter, state, configService, options);
return;
}
// Stage 4: SOUL.md
await soulSetupStage(prompter, state);
// ── Interactive: Main Menu ─────────────────────────────────────────────────
if (state.installAction === 'fresh' || state.installAction === 'reset') {
await runMenuLoop(prompter, state, configService, options);
} else if (state.installAction === 'reconfigure') {
state.mode = 'advanced';
await runMenuLoop(prompter, state, configService, options);
} else {
// 'keep' — skip identity setup, go straight to finalize + gateway
await runKeepPath(prompter, state, configService, options);
}
}
// Stage 5: USER.md
await userSetupStage(prompter, state);
// ── Menu-driven interactive flow ────────────────────────────────────────────
// Stage 6: TOOLS.md
await toolsSetupStage(prompter, state);
type MenuChoice =
| 'quick-start'
| 'providers'
| 'identity'
| 'skills'
| 'gateway-config'
| 'advanced'
| 'finish';
// Stage 7: Runtime Detection & Installation
await runtimeSetupStage(prompter, state);
function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
const labels: Record<MenuChoice, string> = {
'quick-start': 'Quick Start',
providers: 'Providers',
identity: 'Agent Identity',
skills: 'Skills',
'gateway-config': 'Gateway',
advanced: 'Advanced',
finish: 'Finish & Apply',
};
const base = labels[section];
const sectionKey: MenuSection =
section === 'gateway-config' ? 'gateway' : (section as MenuSection);
if (completed.has(sectionKey)) {
return `${base} [done]`;
}
return base;
}
// Stage 8: Hooks preview (Claude only — skipped if Claude not detected)
await hooksPreviewStage(prompter, state);
async function runMenuLoop(
prompter: WizardPrompter,
state: WizardState,
configService: ConfigService,
options: WizardOptions,
): Promise<void> {
const completed = state.completedSections!;
// Stage 9: Skills Selection
for (;;) {
const choice = await prompter.select<MenuChoice>({
message: 'What would you like to configure?',
options: [
{
value: 'quick-start',
label: menuLabel('quick-start', completed),
hint: 'Recommended defaults, minimal questions',
},
{
value: 'providers',
label: menuLabel('providers', completed),
hint: 'LLM API keys (Anthropic, OpenAI)',
},
{
value: 'identity',
label: menuLabel('identity', completed),
hint: 'Agent name, intent, persona',
},
{
value: 'skills',
label: menuLabel('skills', completed),
hint: 'Install agent skills',
},
{
value: 'gateway-config',
label: menuLabel('gateway-config', completed),
hint: 'Port, storage, database',
},
{
value: 'advanced',
label: menuLabel('advanced', completed),
hint: 'SOUL.md, USER.md, TOOLS.md, runtimes, hooks',
},
{
value: 'finish',
label: menuLabel('finish', completed),
hint: 'Write configs and start gateway',
},
],
});
switch (choice) {
case 'quick-start':
await quickStartPath(prompter, state, configService, options);
return; // Quick start is a complete flow — exit menu
case 'providers':
await providerSetupStage(prompter, state);
completed.add('providers');
break;
case 'identity':
await agentIntentStage(prompter, state);
completed.add('identity');
break;
case 'skills':
await skillsSelectStage(prompter, state);
completed.add('skills');
break;
// Stage 10: Finalize (writes configs, links runtime assets, runs doctor)
case 'gateway-config':
// Gateway config is handled during Finish — mark as "configured"
// after user reviews settings.
await runGatewaySubMenu(prompter, state, options);
completed.add('gateway');
break;
case 'advanced':
await runAdvancedSubMenu(prompter, state);
completed.add('advanced');
break;
case 'finish':
await runFinishPath(prompter, state, configService, options);
return; // Done
}
}
}
// ── Gateway sub-menu ─────────────────────────────────────────────────────────
async function runGatewaySubMenu(
prompter: WizardPrompter,
state: WizardState,
_options: WizardOptions,
): Promise<void> {
prompter.note(
'Gateway settings will be applied when you select "Finish & Apply".\n' +
'Configure the settings you want to customize here.',
'Gateway Configuration',
);
// For now, just let them know defaults will be used and they can
// override during finish. The actual gateway config stage runs
// during Finish & Apply. This menu item exists so users know
// the gateway is part of the wizard.
const port = await prompter.text({
message: 'Gateway port',
initialValue: (_options.gatewayPort ?? 14242).toString(),
defaultValue: (_options.gatewayPort ?? 14242).toString(),
validate: (v) => {
const n = parseInt(v, 10);
if (Number.isNaN(n) || n < 1 || n > 65535) return 'Port must be 1-65535';
return undefined;
},
});
// Store for later use in the gateway config stage
_options.gatewayPort = parseInt(port, 10);
prompter.log(`Gateway port set to ${port}. Will be applied during Finish & Apply.`);
}
// ── Advanced sub-menu ────────────────────────────────────────────────────────
async function runAdvancedSubMenu(prompter: WizardPrompter, state: WizardState): Promise<void> {
state.mode = 'advanced';
// Run the detailed setup stages
await soulSetupStage(prompter, state);
await userSetupStage(prompter, state);
await toolsSetupStage(prompter, state);
await runtimeSetupStage(prompter, state);
await hooksPreviewStage(prompter, state);
}
// ── Finish & Apply ──────────────────────────────────────────────────────────
async function runFinishPath(
prompter: WizardPrompter,
state: WizardState,
configService: ConfigService,
options: WizardOptions,
): Promise<void> {
// Apply defaults for anything not explicitly configured
state.soul.agentName ??= 'Mosaic';
state.soul.roleDescription ??= DEFAULTS.roleDescription;
state.soul.communicationStyle ??= 'direct';
state.user.background ??= DEFAULTS.background;
state.user.accessibilitySection ??= DEFAULTS.accessibilitySection;
state.user.personalBoundaries ??= DEFAULTS.personalBoundaries;
state.tools.gitProviders ??= [];
state.tools.credentialsLocation ??= DEFAULTS.credentialsLocation;
state.tools.customToolsSection ??= DEFAULTS.customToolsSection;
// Runtime detection if not already done
if (state.runtimes.detected.length === 0 && !state.completedSections?.has('advanced')) {
await runtimeSetupStage(prompter, state);
await hooksPreviewStage(prompter, state);
}
// Skills defaults if not already configured
if (!state.completedSections?.has('skills')) {
await skillsSelectStage(prompter, state);
}
// Finalize (writes configs, links runtime assets, syncs skills)
await finalizeStage(prompter, state, configService);
// Stages 11 & 12: Gateway config + admin bootstrap.
// The unified first-run flow runs these as terminal stages so the user
// goes from "welcome" through "admin user created" in a single cohesive
// experience. Callers that only want the framework portion pass
// `skipGateway: true`.
// Gateway stages
if (!options.skipGateway) {
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
try {
const configResult = await gatewayConfigStage(prompter, state, {
host: options.gatewayHost ?? 'localhost',
defaultPort: options.gatewayPort ?? 14242,
portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall,
providerKey: state.providerKey,
providerType: state.providerType ?? 'none',
});
if (configResult.ready && configResult.host && configResult.port) {
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host,
port: configResult.port,
});
if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1);
}
}
} catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
}
}
// ── Headless linear path (backward compat) ──────────────────────────────────
async function runHeadlessPath(
prompter: WizardPrompter,
state: WizardState,
configService: ConfigService,
options: WizardOptions,
): Promise<void> {
// Provider setup from env vars
await providerSetupStage(prompter, state);
// Agent intent from env vars
await agentIntentStage(prompter, state);
// SOUL.md
await soulSetupStage(prompter, state);
// USER.md
await userSetupStage(prompter, state);
// TOOLS.md
await toolsSetupStage(prompter, state);
// Runtime Detection
await runtimeSetupStage(prompter, state);
// Hooks
await hooksPreviewStage(prompter, state);
// Skills
await skillsSelectStage(prompter, state);
// Finalize
await finalizeStage(prompter, state, configService);
// Gateway stages
if (!options.skipGateway) {
try {
const configResult = await gatewayConfigStage(prompter, state, {
host: options.gatewayHost ?? 'localhost',
defaultPort: options.gatewayPort ?? 14242,
portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall,
providerKey: state.providerKey,
providerType: state.providerType ?? 'none',
});
if (!configResult.ready || !configResult.host || !configResult.port) {
if (headlessRun) {
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
process.exit(1);
}
} else {
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host,
@@ -150,12 +403,53 @@ export async function runWizard(options: WizardOptions): Promise<void> {
}
}
} catch (err) {
// Stages normally return structured `ready: false` results for
// expected failures. Anything that reaches here is an unexpected
// runtime error — render a concise warning for UX AND re-throw so
// the CLI (and `tools/install.sh` auto-launch) sees a non-zero exit.
// Swallowing here would let headless installs report success even
// when the gateway stage crashed.
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}
}
}
// ── Keep path (preserve existing identity) ──────────────────────────────────
async function runKeepPath(
prompter: WizardPrompter,
state: WizardState,
configService: ConfigService,
options: WizardOptions,
): Promise<void> {
// Runtime detection
await runtimeSetupStage(prompter, state);
// Hooks
await hooksPreviewStage(prompter, state);
// Skills
await skillsSelectStage(prompter, state);
// Finalize
await finalizeStage(prompter, state, configService);
// Gateway stages
if (!options.skipGateway) {
try {
const configResult = await gatewayConfigStage(prompter, state, {
host: options.gatewayHost ?? 'localhost',
defaultPort: options.gatewayPort ?? 14242,
portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall,
});
if (configResult.ready && configResult.host && configResult.port) {
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host,
port: configResult.port,
});
if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1);
}
}
} catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err;
}

View File

@@ -3,7 +3,7 @@
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/prdy"
},
"main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.3",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/quality-rails"
},
"type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.4",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/queue"
},
"main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.4",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/storage"
},
"main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/types"
},
"main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "plugins/discord"
},
"main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "plugins/macp"
},
"type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "plugins/mosaic-framework"
},
"type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "plugins/telegram"
},
"main": "dist/index.js",

View File

@@ -5,11 +5,11 @@
# 1. Mosaic framework → ~/.config/mosaic/ (bash launcher, guides, runtime configs, tools)
# 2. @mosaicstack/mosaic (npm) → ~/.npm-global/ (CLI, TUI, gateway client, wizard)
#
# Remote install (recommended):
# bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
# Quick: curl -fsSL https://mosaicstack.dev/install.sh | bash
# Direct: bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
#
# Remote install (alternative — use -s -- to pass flags):
# curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh | bash -s --
# curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh | bash -s --
#
# Flags:
# --check Version check only, no install
@@ -69,7 +69,7 @@ REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstac
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
CLI_PKG="${SCOPE}/mosaic"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/mosaic-stack"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
# ─── uninstall path ───────────────────────────────────────────────────────────