Compare commits

...

23 Commits

Author SHA1 Message Date
Jarvis
a08da9164a chore: bump @mosaic/mosaic and @mosaic/cli to 0.0.4
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-04-02 20:49:09 -05:00
Jarvis
e83674ac51 fix: mosaic sync — auto-stash dirty worktree before pull --rebase
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
git pull --rebase fails with 'cannot pull with rebase: You have
unstaged changes' when the skills repo has local modifications.

Fix: detect dirty index/worktree, stash before pull, restore after.
Also gracefully handle pull failures (warn and continue with existing
checkout) and stash pop conflicts.
2026-04-02 20:41:11 -05:00
Jarvis
a6e59bf829 fix: syncDirectory — guard same-path copy and skip nested .git dirs
Two bugs causing 'EACCES: permission denied, copyfile' when source
and target are the same path (e.g. wizard with sourceDir == mosaicHome):

1. No same-path guard — syncDirectory tried to copy every file onto
   itself; git pack files are read-only (0444) so copyFileSync fails.
2. excludeGit only matched top-level .git — nested .git dirs like
   sources/agent-skills/.git were copied, hitting the same permission
   issue.

Fixes:
- Early return when resolve(source) === resolve(target)
- Match .git dirs at any depth via dirName and relPath checks
- Skip files inside .git/ paths

Added file-ops.test.ts with 4 tests covering all cases.
2026-04-02 20:41:11 -05:00
e46f0641f6 Merge pull request 'fix: make mosaic init idempotent — detect existing config files' (#355) from fix/idempotent-init into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Reviewed-on: mosaic/mosaic-stack#355
2026-04-03 01:30:01 +00:00
Jarvis
07efaa9580 chore: bump @mosaic/mosaic and @mosaic/cli to 0.0.3
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-04-02 20:26:01 -05:00
Jarvis
361fece023 fix: make mosaic init idempotent — detect existing config files
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
- mosaic-init bash script: detect existing SOUL.md/USER.md/TOOLS.md and
  prompt user to keep, import (re-use values as defaults), or overwrite.
  Non-interactive mode exits cleanly unless --force is passed.
  Overwrite creates timestamped backups before replacing files.

- launch.ts checkSoul(): prefer 'mosaic wizard' over legacy bash script
  when SOUL.md is missing, with fallback to mosaic-init.

- detect-install.ts: pre-populate wizard state with existing values when
  user chooses 'reconfigure', so they see current settings as defaults.

- soul-setup.ts: show existing agent name and communication style as
  defaults during reconfiguration.

- Added tests for reconfigure pre-population and reset non-population.
2026-04-02 20:20:59 -05:00
80e69016b0 Merge pull request 'chore: bump all packages to 0.0.2 — drop alpha prerelease tag' (#354) from chore/bump-0.0.2 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Reviewed-on: mosaic/mosaic-stack#354
2026-04-03 01:12:24 +00:00
Jarvis
e084a88a9d chore: bump all packages to 0.0.2 — drop alpha prerelease tag
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Switches from 0.0.1-alpha.2 to 0.0.2. Clean semver, no prerelease
suffixes. We're still alpha (0.0.x range).
2026-04-02 20:03:55 -05:00
990a88362f Merge pull request 'feat: complete CLI command parity — coord, prdy, seq, upgrade' (#352) from fix/complete-cli-parity into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Reviewed-on: mosaic/mosaic-stack#352
2026-04-03 00:52:36 +00:00
Jarvis
ea9782b2dc feat: complete CLI command parity — add coord, prdy, seq, upgrade
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
The #351 merge landed before the force-push with full commands.
This adds the missing subcommands:

- mosaic coord {init,status,mission,continue,run,smoke,resume}
  → delegates to tools/orchestrator/*.sh with --claude/--codex/--pi/--yolo
- mosaic prdy {init,update,validate,status}
  → delegates to tools/prdy/*.sh with --claude/--codex/--pi
- mosaic seq {check,fix,start}
  → sequential-thinking MCP management (native TS)
- mosaic upgrade {release,check,project}
  → delegates to tools/_scripts/mosaic-release-upgrade and mosaic-upgrade

Also removes duplicate prdy registration (was in both launch.ts and
the old registerPrdyCommand — now only in launch.ts).
2026-04-02 19:51:34 -05:00
8efbaf100e Merge pull request 'feat: unify mosaic CLI — single binary, no PATH conflict' (#351) from feat/unify-mosaic-cli into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-03 00:41:06 +00:00
Jarvis
15830e2f2a feat!: unify mosaic CLI — native launcher, no bin/ directory
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
BREAKING CHANGE: ~/.config/mosaic/bin/ is removed entirely.
The mosaic npm CLI is now the only executable.

## What changed

- **bin/ → deleted**: All scripts moved to tools/_scripts/ (internal)
- **mosaic-launch → deleted**: Launcher logic is native TypeScript
  in packages/cli/src/commands/launch.ts
- **mosaic.ps1 → deleted**: PowerShell launcher removed
- **Framework install.sh**: Complete rewrite with migration system
- **Version tracking**: .framework-version file (schema v2)
- **Migration v1→v2**: Auto-removes bin/, cleans old PATH entries
  from shell profiles

## Native TypeScript launcher (commands/launch.ts)

All runtime launch logic ported from bash:
- Runtime prompt builder (AGENTS.md + RUNTIME.md + USER.md + TOOLS.md)
- Mission context injection (reads .mosaic/orchestrator/mission.json)
- PRD status injection (scans docs/PRD.md)
- Pre-flight checks (MOSAIC_HOME, AGENTS.md, SOUL.md, runtime binary)
- Session lock management with signal cleanup
- Per-runtime launch: Claude, Codex, OpenCode, Pi
- Yolo mode flags per runtime
- Pi skill discovery + extension loading
- Framework management (init, doctor, sync, bootstrap) delegates
  to tools/_scripts/ bash implementations

## Installer

- tools/install.sh: detects framework by .framework-version or AGENTS.md
- Framework install.sh: migration system with schema versioning
- Forward-compatible: add migrations as numbered blocks
- No PATH manipulation for framework (npm bin is the only PATH entry)
2026-04-02 19:37:13 -05:00
04db8591af Merge pull request 'docs: add project README' (#350) from docs/readme into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Reviewed-on: mosaic/mosaic-stack#350
2026-04-03 00:17:10 +00:00
Jarvis
785d30e065 docs: add project README with install, usage, architecture, and dev guide
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-04-02 19:12:28 -05:00
e57a10913d chore: bump all packages to 0.0.1-alpha.2 (#349)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-02 18:21:23 +00:00
0d12471868 feat: add web search, file edit, MCP management, file refs, and /stop to CLI/TUI (#348)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-02 18:08:30 +00:00
ea371d760d Merge pull request 'feat: unified install.sh + auto-update checker (deprecates mosaic/bootstrap)' (#347) from feat/install-update-checker into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-04-02 05:41:07 +00:00
Jarvis
3b9104429b fix(mosaic): wizard integration test — templates path after monorepo migration
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Templates moved from packages/mosaic/templates/ to
packages/mosaic/framework/templates/ in #345. The test's
existsSync guard silently skipped the copy, causing writeSoul
to early-return without writing SOUL.md.
2026-04-02 00:12:03 -05:00
Jarvis
8a83aed9b1 feat: unify install.sh — single installer for framework + npm CLI
- tools/install.sh now installs both components:
  1. Framework (bash launcher, guides, runtime configs) → ~/.config/mosaic/
  2. @mosaic/cli (TUI, gateway client, wizard) → ~/.npm-global/
- Downloads framework from monorepo archive (no bootstrap repo dependency)
- Supports --framework, --cli, --check, --ref flags
- Delete remote-install.sh and remote-install.ps1 (redundant redirectors)
- Update all stale mosaic/bootstrap references → mosaic/mosaic-stack
- Update README.md with monorepo install instructions

Deprecates: mosaic/bootstrap repo
2026-04-02 00:12:03 -05:00
Jarvis
2f68237046 fix: remove --registry from npm install to avoid 404 on transitive deps
The @mosaic scope registry is configured in ~/.npmrc. Passing --registry
on the install command overrides the default registry for ALL packages,
causing non-@mosaic deps like @clack/prompts to 404 against Gitea.
2026-04-02 00:11:42 -05:00
Jarvis
45f5b9062e feat: install.sh + auto-update checker for CLI
- tools/install.sh: standalone installer/upgrader, curl-pipe safe
  (main() wrapper, process.argv instead of stdin, mkdir -p prefix)
- packages/mosaic/src/runtime/update-checker.ts: version check module
  with 1h cache at ~/.cache/mosaic/update-check.json
- CLI startup: non-blocking background update check on every invocation
- 'mosaic update' command: explicit check + install (--check for CI)
- session-start.sh: warns agents when CLI is outdated
- Proper semver comparison including pre-release precedence
- eslint: allow __tests__ in packages/mosaic for projectService
2026-04-02 00:11:42 -05:00
147f5f1bec Merge pull request 'fix: remove stale bootstrap repo references' (#346) from fix/stale-bootstrap-refs into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-04-02 02:27:49 +00:00
Jason Woltje
f05b198882 fix: remove stale bootstrap repo references from CLI error messages
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
Replace 'cd ~/src/mosaic-bootstrap && bash install.sh' with
'npm install -g @mosaic/mosaic' now that bootstrap is archived.
2026-04-01 21:26:53 -05:00
97 changed files with 3418 additions and 1781 deletions

244
README.md Normal file
View File

@@ -0,0 +1,244 @@
# Mosaic Stack
Self-hosted, multi-user AI agent platform. One config, every runtime, same standards.
Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi — injecting consistent system prompts, guardrails, skills, and mission context into every session. A NestJS gateway provides the API surface, a Next.js dashboard gives you the UI, and a plugin system connects Discord, Telegram, and more.
## Quick Install
```bash
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
```
This installs both components:
| Component | What | Where |
| --------------- | ----------------------------------------------------- | -------------------- |
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@mosaic/cli** | TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
After install, set up your agent identity:
```bash
mosaic init # Interactive wizard
```
### Requirements
- Node.js ≥ 20
- npm (for global @mosaic/cli install)
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
## Usage
### Launching Agent Sessions
```bash
mosaic pi # Launch Pi with Mosaic injection
mosaic claude # Launch Claude Code with Mosaic injection
mosaic codex # Launch Codex with Mosaic injection
mosaic opencode # Launch OpenCode with Mosaic injection
mosaic yolo claude # Claude with dangerous-permissions mode
mosaic yolo pi # Pi in yolo mode
```
The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md` standards into the runtime, and forwards all arguments.
### TUI & Gateway
```bash
mosaic tui # Interactive TUI connected to the gateway
mosaic login # Authenticate with a gateway instance
mosaic sessions list # List active agent sessions
```
### Management
```bash
mosaic doctor # Health audit — detect drift and missing files
mosaic sync # Sync skills from canonical source
mosaic update # Check for and install CLI updates
mosaic wizard # Full guided setup wizard
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
mosaic coord init # Initialize a new orchestration mission
mosaic prdy init # Create a PRD via guided session
```
## Development
### Prerequisites
- Node.js ≥ 20
- pnpm 10.6+
- Docker & Docker Compose
### Setup
```bash
git clone git@git.mosaicstack.dev:mosaic/mosaic-stack.git
cd mosaic-stack
# Start infrastructure (Postgres, Valkey, Jaeger)
docker compose up -d
# Install dependencies
pnpm install
# Run migrations
pnpm --filter @mosaic/db run db:migrate
# Start all services in dev mode
pnpm dev
```
### Infrastructure
Docker Compose provides:
| Service | Port | Purpose |
| --------------------- | --------- | ---------------------- |
| PostgreSQL (pgvector) | 5433 | Primary database |
| Valkey | 6380 | Task queue + caching |
| Jaeger | 16686 | Distributed tracing UI |
| OTEL Collector | 4317/4318 | Telemetry ingestion |
### Quality Gates
```bash
pnpm typecheck # TypeScript type checking (all packages)
pnpm lint # ESLint (all packages)
pnpm test # Vitest (all packages)
pnpm format:check # Prettier check
pnpm format # Prettier auto-fix
```
### CI
Woodpecker CI runs on every push:
- `pnpm install --frozen-lockfile`
- Database migration against a fresh Postgres
- `pnpm test` (Turbo-orchestrated across all packages)
npm packages are published to the Gitea package registry on main merges.
## Architecture
```
mosaic-stack/
├── apps/
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
│ └── web/ Next.js dashboard (React 19, Tailwind)
├── packages/
│ ├── cli/ Mosaic CLI — TUI, gateway client, wizard
│ ├── mosaic/ Framework — wizard, runtime detection, update checker
│ ├── types/ Shared TypeScript contracts (Socket.IO typed events)
│ ├── db/ Drizzle ORM schema + migrations (pgvector)
│ ├── auth/ BetterAuth configuration
│ ├── brain/ Data layer (PG-backed)
│ ├── queue/ Valkey task queue + MCP
│ ├── coord/ Mission coordination
│ ├── forge/ Multi-stage AI pipeline (intake → board → plan → code → review)
│ ├── macp/ MACP protocol — credential resolution, gate runner, events
│ ├── agent/ Agent session management
│ ├── memory/ Agent memory layer
│ ├── log/ Structured logging
│ ├── prdy/ PRD creation and validation
│ ├── quality-rails/ Quality templates (TypeScript, Next.js, monorepo)
│ └── design-tokens/ Shared design tokens
├── plugins/
│ ├── discord/ Discord channel plugin (discord.js)
│ ├── telegram/ Telegram channel plugin (Telegraf)
│ ├── macp/ OpenClaw MACP runtime plugin
│ └── mosaic-framework/ OpenClaw framework injection plugin
├── tools/
│ └── install.sh Unified installer (framework + npm CLI)
├── scripts/agent/ Agent session lifecycle scripts
├── docker-compose.yml Dev infrastructure
└── .woodpecker/ CI pipeline configs
```
### Key Design Decisions
- **Gateway is the single API surface** — all clients (TUI, web, Discord, Telegram) connect through it
- **ESM everywhere** — `"type": "module"`, `.js` extensions in imports, NodeNext resolution
- **Socket.IO typed events** — defined in `@mosaic/types`, enforced at compile time
- **OTEL auto-instrumentation** — loads before NestJS bootstrap
- **Explicit `@Inject()` decorators** — required since tsx/esbuild doesn't emit decorator metadata
### Framework (`~/.config/mosaic/`)
The framework is the bash-based standards layer installed to every developer machine:
```
~/.config/mosaic/
├── AGENTS.md ← Central standards (loaded into every runtime)
├── SOUL.md ← Agent identity (name, style, guardrails)
├── USER.md ← User profile (name, timezone, preferences)
├── TOOLS.md ← Machine-level tool reference
├── bin/mosaic ← Unified launcher (claude, codex, opencode, pi, yolo)
├── guides/ ← E2E delivery, orchestrator protocol, PRD, etc.
├── runtime/ ← Per-runtime configs (claude/, codex/, opencode/, pi/)
├── skills/ ← Universal skills (synced from agent-skills repo)
├── tools/ ← Tool suites (orchestrator, git, quality, prdy, etc.)
└── memory/ ← Persistent agent memory (preserved across upgrades)
```
### Forge Pipeline
Forge is a multi-stage AI pipeline for autonomous feature delivery:
```
Intake → Discovery → Board Review → Planning (3 stages) → Coding → Review → Remediation → Test → Deploy
```
Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding), quality gates, and timeouts. The board review uses multiple AI personas (CEO, CTO, CFO, COO + specialists) to evaluate briefs before committing resources.
## Upgrading
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)
```
Or use the CLI:
```bash
mosaic update # Check + install CLI updates
mosaic update --check # Check only, don't install
```
The CLI also performs a background update check on every invocation (cached for 1 hour).
### Installer Flags
```bash
bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref
```
## Contributing
```bash
# Create a feature branch
git checkout -b feat/my-feature
# Make changes, then verify
pnpm typecheck && pnpm lint && pnpm test && pnpm format:check
# Commit (husky runs lint-staged automatically)
git commit -m "feat: description of change"
# Push and create PR
git push -u origin feat/my-feature
```
DTOs go in `*.dto.ts` files at module boundaries. Scratchpads (`docs/scratchpads/`) are mandatory for non-trivial tasks. See `AGENTS.md` for the full standards reference.
## License
Proprietary — all rights reserved.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/gateway", "name": "@mosaic/gateway",
"version": "0.0.0", "version": "0.0.2",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "dist/main.js", "main": "dist/main.js",

View File

@@ -23,6 +23,7 @@ import { createFileTools } from './tools/file-tools.js';
import { createGitTools } from './tools/git-tools.js'; import { createGitTools } from './tools/git-tools.js';
import { createShellTools } from './tools/shell-tools.js'; import { createShellTools } from './tools/shell-tools.js';
import { createWebTools } from './tools/web-tools.js'; import { createWebTools } from './tools/web-tools.js';
import { createSearchTools } from './tools/search-tools.js';
import type { SessionInfoDto, SessionMetrics } from './session.dto.js'; import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
import { SystemOverrideService } from '../preferences/system-override.service.js'; import { SystemOverrideService } from '../preferences/system-override.service.js';
import { PreferencesService } from '../preferences/preferences.service.js'; import { PreferencesService } from '../preferences/preferences.service.js';
@@ -146,6 +147,7 @@ export class AgentService implements OnModuleDestroy {
...createGitTools(sandboxDir), ...createGitTools(sandboxDir),
...createShellTools(sandboxDir), ...createShellTools(sandboxDir),
...createWebTools(), ...createWebTools(),
...createSearchTools(),
]; ];
} }

View File

@@ -190,5 +190,169 @@ export function createFileTools(baseDir: string): ToolDefinition[] {
}, },
}; };
return [readFileTool, writeFileTool, listDirectoryTool]; const editFileTool: ToolDefinition = {
name: 'fs_edit_file',
label: 'Edit File',
description:
'Make targeted text replacements in a file. Each edit replaces an exact match of oldText with newText. ' +
'All edits are matched against the original file content (not incrementally). ' +
'Each oldText must be unique in the file and edits must not overlap.',
parameters: Type.Object({
path: Type.String({
description: 'File path (relative to sandbox base or absolute within it)',
}),
edits: Type.Array(
Type.Object({
oldText: Type.String({
description: 'Exact text to find and replace (must be unique in the file)',
}),
newText: Type.String({ description: 'Replacement text' }),
}),
{ description: 'One or more targeted replacements', minItems: 1 },
),
}),
async execute(_toolCallId, params) {
const { path, edits } = params as {
path: string;
edits: Array<{ oldText: string; newText: string }>;
};
let safePath: string;
try {
safePath = guardPath(path, baseDir);
} catch (err) {
if (err instanceof SandboxEscapeError) {
return {
content: [{ type: 'text' as const, text: `Error: ${err.message}` }],
details: undefined,
};
}
return {
content: [{ type: 'text' as const, text: `Error: ${String(err)}` }],
details: undefined,
};
}
try {
const info = await stat(safePath);
if (!info.isFile()) {
return {
content: [{ type: 'text' as const, text: `Error: path is not a file: ${path}` }],
details: undefined,
};
}
if (info.size > MAX_READ_BYTES) {
return {
content: [
{
type: 'text' as const,
text: `Error: file too large for editing (${info.size} bytes, limit ${MAX_READ_BYTES} bytes)`,
},
],
details: undefined,
};
}
} catch (err) {
return {
content: [{ type: 'text' as const, text: `Error reading file: ${String(err)}` }],
details: undefined,
};
}
let content: string;
try {
content = await readFile(safePath, { encoding: 'utf8' });
} catch (err) {
return {
content: [{ type: 'text' as const, text: `Error reading file: ${String(err)}` }],
details: undefined,
};
}
// Validate all edits before applying any
const errors: string[] = [];
for (let i = 0; i < edits.length; i++) {
const edit = edits[i]!;
const occurrences = content.split(edit.oldText).length - 1;
if (occurrences === 0) {
errors.push(`Edit ${i + 1}: oldText not found in file`);
} else if (occurrences > 1) {
errors.push(`Edit ${i + 1}: oldText matches ${occurrences} locations (must be unique)`);
}
}
// Check for overlapping edits
if (errors.length === 0) {
const positions = edits.map((edit, i) => ({
index: i,
start: content.indexOf(edit.oldText),
end: content.indexOf(edit.oldText) + edit.oldText.length,
}));
positions.sort((a, b) => a.start - b.start);
for (let i = 1; i < positions.length; i++) {
if (positions[i]!.start < positions[i - 1]!.end) {
errors.push(
`Edits ${positions[i - 1]!.index + 1} and ${positions[i]!.index + 1} overlap`,
);
}
}
}
if (errors.length > 0) {
return {
content: [
{
type: 'text' as const,
text: `Edit validation failed:\n${errors.join('\n')}`,
},
],
details: undefined,
};
}
// Apply edits: process from end to start to preserve positions
const positions = edits.map((edit) => ({
edit,
start: content.indexOf(edit.oldText),
}));
positions.sort((a, b) => b.start - a.start); // reverse order
let result = content;
for (const { edit } of positions) {
result = result.replace(edit.oldText, edit.newText);
}
if (Buffer.byteLength(result, 'utf8') > MAX_WRITE_BYTES) {
return {
content: [
{
type: 'text' as const,
text: `Error: resulting file too large (limit ${MAX_WRITE_BYTES} bytes)`,
},
],
details: undefined,
};
}
try {
await writeFile(safePath, result, { encoding: 'utf8' });
return {
content: [
{
type: 'text' as const,
text: `File edited successfully: ${path} (${edits.length} edit(s) applied)`,
},
],
details: undefined,
};
} catch (err) {
return {
content: [{ type: 'text' as const, text: `Error writing file: ${String(err)}` }],
details: undefined,
};
}
},
};
return [readFileTool, writeFileTool, listDirectoryTool, editFileTool];
} }

View File

@@ -2,6 +2,7 @@ export { createBrainTools } from './brain-tools.js';
export { createCoordTools } from './coord-tools.js'; export { createCoordTools } from './coord-tools.js';
export { createFileTools } from './file-tools.js'; export { createFileTools } from './file-tools.js';
export { createGitTools } from './git-tools.js'; export { createGitTools } from './git-tools.js';
export { createSearchTools } from './search-tools.js';
export { createShellTools } from './shell-tools.js'; export { createShellTools } from './shell-tools.js';
export { createWebTools } from './web-tools.js'; export { createWebTools } from './web-tools.js';
export { createSkillTools } from './skill-tools.js'; export { createSkillTools } from './skill-tools.js';

View File

@@ -0,0 +1,496 @@
import { Type } from '@sinclair/typebox';
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
const DEFAULT_TIMEOUT_MS = 15_000;
const MAX_RESULTS = 10;
const MAX_RESPONSE_BYTES = 256 * 1024; // 256 KB
// ─── Provider helpers ────────────────────────────────────────────────────────
interface SearchResult {
title: string;
url: string;
snippet: string;
}
interface SearchResponse {
provider: string;
query: string;
results: SearchResult[];
error?: string;
}
async function fetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs: number,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
async function readLimited(response: Response): Promise<string> {
const reader = response.body?.getReader();
if (!reader) return '';
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.length;
if (total > MAX_RESPONSE_BYTES) {
chunks.push(value.subarray(0, MAX_RESPONSE_BYTES - (total - value.length)));
reader.cancel();
break;
}
chunks.push(value);
}
const combined = new Uint8Array(chunks.reduce((a, c) => a + c.length, 0));
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
return new TextDecoder().decode(combined);
}
// ─── Brave Search ────────────────────────────────────────────────────────────
async function searchBrave(query: string, limit: number): Promise<SearchResponse> {
const apiKey = process.env['BRAVE_API_KEY'];
if (!apiKey) return { provider: 'brave', query, results: [], error: 'BRAVE_API_KEY not set' };
try {
const params = new URLSearchParams({
q: query,
count: String(Math.min(limit, 20)),
});
const res = await fetchWithTimeout(
`https://api.search.brave.com/res/v1/web/search?${params}`,
{ headers: { 'X-Subscription-Token': apiKey, Accept: 'application/json' } },
DEFAULT_TIMEOUT_MS,
);
if (!res.ok) {
const body = await res.text().catch(() => '');
return { provider: 'brave', query, results: [], error: `HTTP ${res.status}: ${body}` };
}
const data = (await res.json()) as {
web?: { results?: Array<{ title: string; url: string; description: string }> };
};
const results: SearchResult[] = (data.web?.results ?? []).slice(0, limit).map((r) => ({
title: r.title,
url: r.url,
snippet: r.description,
}));
return { provider: 'brave', query, results };
} catch (err) {
return {
provider: 'brave',
query,
results: [],
error: err instanceof Error ? err.message : String(err),
};
}
}
// ─── Tavily Search ───────────────────────────────────────────────────────────
async function searchTavily(query: string, limit: number): Promise<SearchResponse> {
const apiKey = process.env['TAVILY_API_KEY'];
if (!apiKey) return { provider: 'tavily', query, results: [], error: 'TAVILY_API_KEY not set' };
try {
const res = await fetchWithTimeout(
'https://api.tavily.com/search',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: apiKey,
query,
max_results: Math.min(limit, 10),
include_answer: false,
}),
},
DEFAULT_TIMEOUT_MS,
);
if (!res.ok) {
const body = await res.text().catch(() => '');
return { provider: 'tavily', query, results: [], error: `HTTP ${res.status}: ${body}` };
}
const data = (await res.json()) as {
results?: Array<{ title: string; url: string; content: string }>;
};
const results: SearchResult[] = (data.results ?? []).slice(0, limit).map((r) => ({
title: r.title,
url: r.url,
snippet: r.content,
}));
return { provider: 'tavily', query, results };
} catch (err) {
return {
provider: 'tavily',
query,
results: [],
error: err instanceof Error ? err.message : String(err),
};
}
}
// ─── SearXNG (self-hosted) ───────────────────────────────────────────────────
async function searchSearxng(query: string, limit: number): Promise<SearchResponse> {
const baseUrl = process.env['SEARXNG_URL'];
if (!baseUrl) return { provider: 'searxng', query, results: [], error: 'SEARXNG_URL not set' };
try {
const params = new URLSearchParams({
q: query,
format: 'json',
pageno: '1',
});
const res = await fetchWithTimeout(
`${baseUrl.replace(/\/$/, '')}/search?${params}`,
{ headers: { Accept: 'application/json' } },
DEFAULT_TIMEOUT_MS,
);
if (!res.ok) {
const body = await res.text().catch(() => '');
return { provider: 'searxng', query, results: [], error: `HTTP ${res.status}: ${body}` };
}
const data = (await res.json()) as {
results?: Array<{ title: string; url: string; content: string }>;
};
const results: SearchResult[] = (data.results ?? []).slice(0, limit).map((r) => ({
title: r.title,
url: r.url,
snippet: r.content,
}));
return { provider: 'searxng', query, results };
} catch (err) {
return {
provider: 'searxng',
query,
results: [],
error: err instanceof Error ? err.message : String(err),
};
}
}
// ─── DuckDuckGo (lite HTML endpoint) ─────────────────────────────────────────
async function searchDuckDuckGo(query: string, limit: number): Promise<SearchResponse> {
try {
// Use the DuckDuckGo Instant Answer API (JSON, free, no key)
const params = new URLSearchParams({
q: query,
format: 'json',
no_html: '1',
skip_disambig: '1',
});
const res = await fetchWithTimeout(
`https://api.duckduckgo.com/?${params}`,
{ headers: { Accept: 'application/json' } },
DEFAULT_TIMEOUT_MS,
);
if (!res.ok) {
return {
provider: 'duckduckgo',
query,
results: [],
error: `HTTP ${res.status}`,
};
}
const text = await readLimited(res);
const data = JSON.parse(text) as {
AbstractText?: string;
AbstractURL?: string;
AbstractSource?: string;
RelatedTopics?: Array<{
Text?: string;
FirstURL?: string;
Result?: string;
Topics?: Array<{ Text?: string; FirstURL?: string }>;
}>;
};
const results: SearchResult[] = [];
// Main abstract result
if (data.AbstractText && data.AbstractURL) {
results.push({
title: data.AbstractSource ?? 'DuckDuckGo Abstract',
url: data.AbstractURL,
snippet: data.AbstractText,
});
}
// Related topics
for (const topic of data.RelatedTopics ?? []) {
if (results.length >= limit) break;
if (topic.Text && topic.FirstURL) {
results.push({
title: topic.Text.slice(0, 120),
url: topic.FirstURL,
snippet: topic.Text,
});
}
// Sub-topics
for (const sub of topic.Topics ?? []) {
if (results.length >= limit) break;
if (sub.Text && sub.FirstURL) {
results.push({
title: sub.Text.slice(0, 120),
url: sub.FirstURL,
snippet: sub.Text,
});
}
}
}
return { provider: 'duckduckgo', query, results: results.slice(0, limit) };
} catch (err) {
return {
provider: 'duckduckgo',
query,
results: [],
error: err instanceof Error ? err.message : String(err),
};
}
}
// ─── Provider resolution ─────────────────────────────────────────────────────
type SearchProvider = 'brave' | 'tavily' | 'searxng' | 'duckduckgo' | 'auto';
function getAvailableProviders(): SearchProvider[] {
const available: SearchProvider[] = [];
if (process.env['BRAVE_API_KEY']) available.push('brave');
if (process.env['TAVILY_API_KEY']) available.push('tavily');
if (process.env['SEARXNG_URL']) available.push('searxng');
// DuckDuckGo is always available (no API key needed)
available.push('duckduckgo');
return available;
}
async function executeSearch(
provider: SearchProvider,
query: string,
limit: number,
): Promise<SearchResponse> {
switch (provider) {
case 'brave':
return searchBrave(query, limit);
case 'tavily':
return searchTavily(query, limit);
case 'searxng':
return searchSearxng(query, limit);
case 'duckduckgo':
return searchDuckDuckGo(query, limit);
case 'auto': {
// Try providers in priority order: Brave > Tavily > SearXNG > DuckDuckGo
const available = getAvailableProviders();
for (const p of available) {
const result = await executeSearch(p, query, limit);
if (!result.error && result.results.length > 0) return result;
}
// Fall back to DuckDuckGo if everything failed
return searchDuckDuckGo(query, limit);
}
}
}
function formatSearchResults(response: SearchResponse): string {
const lines: string[] = [];
lines.push(`Search provider: ${response.provider}`);
lines.push(`Query: "${response.query}"`);
if (response.error) {
lines.push(`Error: ${response.error}`);
}
if (response.results.length === 0) {
lines.push('No results found.');
} else {
lines.push(`Results (${response.results.length}):\n`);
for (let i = 0; i < response.results.length; i++) {
const r = response.results[i]!;
lines.push(`${i + 1}. ${r.title}`);
lines.push(` URL: ${r.url}`);
lines.push(` ${r.snippet}`);
lines.push('');
}
}
return lines.join('\n');
}
// ─── Tool exports ────────────────────────────────────────────────────────────
export function createSearchTools(): ToolDefinition[] {
const webSearch: ToolDefinition = {
name: 'web_search',
label: 'Web Search',
description:
'Search the web using configured search providers. ' +
'Supports Brave, Tavily, SearXNG, and DuckDuckGo. ' +
'Use "auto" provider to pick the best available. ' +
'DuckDuckGo is always available as a fallback (no API key needed).',
parameters: Type.Object({
query: Type.String({ description: 'Search query' }),
provider: Type.Optional(
Type.String({
description:
'Search provider: "auto" (default), "brave", "tavily", "searxng", or "duckduckgo"',
}),
),
limit: Type.Optional(
Type.Number({ description: `Max results to return (default 5, max ${MAX_RESULTS})` }),
),
}),
async execute(_toolCallId, params) {
const { query, provider, limit } = params as {
query: string;
provider?: string;
limit?: number;
};
const effectiveProvider = (provider ?? 'auto') as SearchProvider;
const validProviders = ['auto', 'brave', 'tavily', 'searxng', 'duckduckgo'];
if (!validProviders.includes(effectiveProvider)) {
return {
content: [
{
type: 'text' as const,
text: `Invalid provider "${provider}". Valid: ${validProviders.join(', ')}`,
},
],
details: undefined,
};
}
const effectiveLimit = Math.min(Math.max(limit ?? 5, 1), MAX_RESULTS);
try {
const response = await executeSearch(effectiveProvider, query, effectiveLimit);
return {
content: [{ type: 'text' as const, text: formatSearchResults(response) }],
details: undefined,
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Search failed: ${err instanceof Error ? err.message : String(err)}`,
},
],
details: undefined,
};
}
},
};
const webSearchNews: ToolDefinition = {
name: 'web_search_news',
label: 'Web Search (News)',
description:
'Search for recent news articles. Uses Brave News API if available, falls back to standard search with news keywords.',
parameters: Type.Object({
query: Type.String({ description: 'News search query' }),
limit: Type.Optional(
Type.Number({ description: `Max results (default 5, max ${MAX_RESULTS})` }),
),
}),
async execute(_toolCallId, params) {
const { query, limit } = params as { query: string; limit?: number };
const effectiveLimit = Math.min(Math.max(limit ?? 5, 1), MAX_RESULTS);
// Try Brave News API first (dedicated news endpoint)
const braveKey = process.env['BRAVE_API_KEY'];
if (braveKey) {
try {
const newsParams = new URLSearchParams({
q: query,
count: String(effectiveLimit),
});
const res = await fetchWithTimeout(
`https://api.search.brave.com/res/v1/news/search?${newsParams}`,
{
headers: {
'X-Subscription-Token': braveKey,
Accept: 'application/json',
},
},
DEFAULT_TIMEOUT_MS,
);
if (res.ok) {
const data = (await res.json()) as {
results?: Array<{
title: string;
url: string;
description: string;
age?: string;
}>;
};
const results: SearchResult[] = (data.results ?? [])
.slice(0, effectiveLimit)
.map((r) => ({
title: r.title + (r.age ? ` (${r.age})` : ''),
url: r.url,
snippet: r.description,
}));
const response: SearchResponse = { provider: 'brave-news', query, results };
return {
content: [{ type: 'text' as const, text: formatSearchResults(response) }],
details: undefined,
};
}
} catch {
// Fall through to generic search
}
}
// Fallback: standard search with "news" appended
const newsQuery = `${query} news latest`;
const response = await executeSearch('auto', newsQuery, effectiveLimit);
return {
content: [{ type: 'text' as const, text: formatSearchResults(response) }],
details: undefined,
};
},
};
const searchProviders: ToolDefinition = {
name: 'web_search_providers',
label: 'List Search Providers',
description: 'List the currently available and configured web search providers.',
parameters: Type.Object({}),
async execute() {
const available = getAvailableProviders();
const allProviders = [
{ name: 'brave', configured: !!process.env['BRAVE_API_KEY'], envVar: 'BRAVE_API_KEY' },
{ name: 'tavily', configured: !!process.env['TAVILY_API_KEY'], envVar: 'TAVILY_API_KEY' },
{ name: 'searxng', configured: !!process.env['SEARXNG_URL'], envVar: 'SEARXNG_URL' },
{ name: 'duckduckgo', configured: true, envVar: '(none — always available)' },
];
const lines = ['Search providers:\n'];
for (const p of allProviders) {
const status = p.configured ? '✓ configured' : '✗ not configured';
lines.push(` ${p.name}: ${status} (${p.envVar})`);
}
lines.push(`\nActive providers for "auto" mode: ${available.join(', ')}`);
return {
content: [{ type: 'text' as const, text: lines.join('\n') }],
details: undefined,
};
},
};
return [webSearch, webSearchNews, searchProviders];
}

View File

@@ -18,6 +18,7 @@ import type {
SlashCommandPayload, SlashCommandPayload,
SystemReloadPayload, SystemReloadPayload,
RoutingDecisionInfo, RoutingDecisionInfo,
AbortPayload,
} from '@mosaic/types'; } from '@mosaic/types';
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
import { AUTH } from '../auth/auth.tokens.js'; import { AUTH } from '../auth/auth.tokens.js';
@@ -325,6 +326,38 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}); });
} }
@SubscribeMessage('abort')
async handleAbort(
@ConnectedSocket() client: Socket,
@MessageBody() data: AbortPayload,
): Promise<void> {
const conversationId = data.conversationId;
this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`);
const session = this.agentService.getSession(conversationId);
if (!session) {
client.emit('error', {
conversationId,
error: 'No active session to abort.',
});
return;
}
try {
await session.piSession.abort();
this.logger.log(`Agent session ${conversationId} aborted successfully`);
} catch (err) {
this.logger.error(
`Failed to abort session ${conversationId}`,
err instanceof Error ? err.stack : String(err),
);
client.emit('error', {
conversationId,
error: 'Failed to abort the agent operation.',
});
}
}
@SubscribeMessage('command:execute') @SubscribeMessage('command:execute')
async handleCommandExecute( async handleCommandExecute(
@ConnectedSocket() client: Socket, @ConnectedSocket() client: Socket,

View File

@@ -82,6 +82,7 @@ function buildService(): CommandExecutorService {
mockBrain as never, mockBrain as never,
null, null,
mockChatGateway as never, mockChatGateway as never,
null,
); );
} }

View File

@@ -7,6 +7,7 @@ import { ChatGateway } from '../chat/chat.gateway.js';
import { SessionGCService } from '../gc/session-gc.service.js'; import { SessionGCService } from '../gc/session-gc.service.js';
import { SystemOverrideService } from '../preferences/system-override.service.js'; import { SystemOverrideService } from '../preferences/system-override.service.js';
import { ReloadService } from '../reload/reload.service.js'; import { ReloadService } from '../reload/reload.service.js';
import { McpClientService } from '../mcp-client/mcp-client.service.js';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { COMMANDS_REDIS } from './commands.tokens.js'; import { COMMANDS_REDIS } from './commands.tokens.js';
import { CommandRegistryService } from './command-registry.service.js'; import { CommandRegistryService } from './command-registry.service.js';
@@ -28,6 +29,9 @@ export class CommandExecutorService {
@Optional() @Optional()
@Inject(forwardRef(() => ChatGateway)) @Inject(forwardRef(() => ChatGateway))
private readonly chatGateway: ChatGateway | null, private readonly chatGateway: ChatGateway | null,
@Optional()
@Inject(McpClientService)
private readonly mcpClient: McpClientService | null,
) {} ) {}
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> { async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
@@ -105,6 +109,8 @@ export class CommandExecutorService {
}; };
case 'tools': case 'tools':
return await this.handleTools(conversationId, userId); return await this.handleTools(conversationId, userId);
case 'mcp':
return await this.handleMcp(args ?? null, conversationId);
case 'reload': { case 'reload': {
if (!this.reloadService) { if (!this.reloadService) {
return { return {
@@ -489,4 +495,92 @@ export class CommandExecutorService {
conversationId, conversationId,
}; };
} }
private async handleMcp(
args: string | null,
conversationId: string,
): Promise<SlashCommandResultPayload> {
if (!this.mcpClient) {
return {
command: 'mcp',
conversationId,
success: false,
message: 'MCP client service is not available.',
};
}
const action = args?.trim().split(/\s+/)[0] ?? 'status';
switch (action) {
case 'status':
case 'servers': {
const statuses = this.mcpClient.getServerStatuses();
if (statuses.length === 0) {
return {
command: 'mcp',
conversationId,
success: true,
message:
'No MCP servers configured. Set MCP_SERVERS env var to connect external tool servers.',
};
}
const lines = ['MCP Server Status:\n'];
for (const s of statuses) {
const status = s.connected ? '✓ connected' : '✗ disconnected';
lines.push(` ${s.name}: ${status}`);
lines.push(` URL: ${s.url}`);
lines.push(` Tools: ${s.toolCount}`);
if (s.error) lines.push(` Error: ${s.error}`);
lines.push('');
}
const tools = this.mcpClient.getToolDefinitions();
if (tools.length > 0) {
lines.push(`Total bridged tools: ${tools.length}`);
lines.push(`Tool names: ${tools.map((t) => t.name).join(', ')}`);
}
return {
command: 'mcp',
conversationId,
success: true,
message: lines.join('\n'),
};
}
case 'reconnect': {
const serverName = args?.trim().split(/\s+/).slice(1).join(' ');
if (!serverName) {
return {
command: 'mcp',
conversationId,
success: false,
message: 'Usage: /mcp reconnect <server-name>',
};
}
try {
await this.mcpClient.reconnectServer(serverName);
return {
command: 'mcp',
conversationId,
success: true,
message: `MCP server "${serverName}" reconnected successfully.`,
};
} catch (err) {
return {
command: 'mcp',
conversationId,
success: false,
message: `Failed to reconnect MCP server "${serverName}": ${err instanceof Error ? err.message : String(err)}`,
};
}
}
default:
return {
command: 'mcp',
conversationId,
success: false,
message: `Unknown MCP action: "${action}". Use: /mcp status, /mcp servers, /mcp reconnect <name>`,
};
}
}
} }

View File

@@ -260,6 +260,23 @@ export class CommandRegistryService implements OnModuleInit {
execution: 'socket', execution: 'socket',
available: true, available: true,
}, },
{
name: 'mcp',
description: 'Manage MCP server connections (status/reconnect/servers)',
aliases: [],
args: [
{
name: 'action',
type: 'enum',
optional: true,
values: ['status', 'reconnect', 'servers'],
description: 'Action: status (default), reconnect <name>, servers',
},
],
scope: 'agent',
execution: 'socket',
available: true,
},
{ {
name: 'reload', name: 'reload',
description: 'Soft-reload gateway plugins and command manifest (admin)', description: 'Soft-reload gateway plugins and command manifest (admin)',

View File

@@ -65,6 +65,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
mockBrain as never, mockBrain as never,
null, // reloadService (optional) null, // reloadService (optional)
null, // chatGateway (optional) null, // chatGateway (optional)
null, // mcpClient (optional)
); );
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/web", "name": "@mosaic/web",
"version": "0.0.0", "version": "0.0.2",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "next build", "build": "next build",

View File

@@ -27,6 +27,7 @@ export default tseslint.config(
'apps/web/e2e/*.ts', 'apps/web/e2e/*.ts',
'apps/web/e2e/helpers/*.ts', 'apps/web/e2e/helpers/*.ts',
'apps/web/playwright.config.ts', 'apps/web/playwright.config.ts',
'packages/mosaic/__tests__/*.ts',
], ],
}, },
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/agent", "name": "@mosaic/agent",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { "exports": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/auth", "name": "@mosaic/auth",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/brain", "name": "@mosaic/brain",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { "exports": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/cli", "name": "@mosaic/cli",
"version": "0.0.1-alpha.1", "version": "0.0.4",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@@ -14,7 +14,7 @@
} }
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc -p tsconfig.build.json",
"dev": "tsx src/cli.ts", "dev": "tsx src/cli.ts",
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",

View File

@@ -5,15 +5,28 @@ import { Command } from 'commander';
import { createQualityRailsCli } from '@mosaic/quality-rails'; import { createQualityRailsCli } from '@mosaic/quality-rails';
import { registerAgentCommand } from './commands/agent.js'; import { registerAgentCommand } from './commands/agent.js';
import { registerMissionCommand } from './commands/mission.js'; import { registerMissionCommand } from './commands/mission.js';
import { registerPrdyCommand } from './commands/prdy.js'; // prdy is registered via launch.ts
import { registerLaunchCommands } from './commands/launch.js';
const _require = createRequire(import.meta.url); const _require = createRequire(import.meta.url);
const CLI_VERSION: string = (_require('../package.json') as { version: string }).version; const CLI_VERSION: string = (_require('../package.json') as { version: string }).version;
// Fire-and-forget update check at startup (non-blocking, cached 1h)
try {
const { backgroundUpdateCheck } = await import('@mosaic/mosaic');
backgroundUpdateCheck();
} catch {
// Silently ignore — update check is best-effort
}
const program = new Command(); const program = new Command();
program.name('mosaic').description('Mosaic Stack CLI').version(CLI_VERSION); program.name('mosaic').description('Mosaic Stack CLI').version(CLI_VERSION);
// ─── runtime launchers + framework commands ────────────────────────────
registerLaunchCommands(program);
// ─── login ────────────────────────────────────────────────────────────── // ─── login ──────────────────────────────────────────────────────────────
program program
@@ -285,10 +298,6 @@ registerAgentCommand(program);
registerMissionCommand(program); registerMissionCommand(program);
// ─── prdy ──────────────────────────────────────────────────────────────
registerPrdyCommand(program);
// ─── quality-rails ────────────────────────────────────────────────────── // ─── quality-rails ──────────────────────────────────────────────────────
const qrWrapper = createQualityRailsCli(); const qrWrapper = createQualityRailsCli();
@@ -297,6 +306,54 @@ if (qrCmd !== undefined) {
program.addCommand(qrCmd as unknown as Command); program.addCommand(qrCmd as unknown as Command);
} }
// ─── update ─────────────────────────────────────────────────────────────
program
.command('update')
.description('Check for and install Mosaic CLI updates')
.option('--check', 'Check only, do not install')
.action(async (opts: { check?: boolean }) => {
const { checkForUpdate, formatUpdateNotice } = await import('@mosaic/mosaic');
const { execSync } = await import('node:child_process');
console.log('Checking for updates…');
const result = checkForUpdate({ skipCache: true });
if (!result.latest) {
console.error('Could not reach the Mosaic registry.');
process.exit(1);
}
console.log(` Installed: ${result.current || '(none)'}`);
console.log(` Latest: ${result.latest}`);
if (!result.updateAvailable) {
console.log('\n✔ Up to date.');
return;
}
const notice = formatUpdateNotice(result);
if (notice) console.log(notice);
if (opts.check) {
process.exit(2); // Signal to callers that an update exists
}
console.log('Installing update…');
try {
// Relies on @mosaic:registry in ~/.npmrc — do NOT pass --registry
// globally or non-@mosaic deps will 404 against the Gitea registry.
execSync('npm install -g @mosaic/cli@latest', {
stdio: 'inherit',
timeout: 60_000,
});
console.log('\n✔ Updated successfully.');
} catch {
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
process.exit(1);
}
});
// ─── wizard ───────────────────────────────────────────────────────────── // ─── wizard ─────────────────────────────────────────────────────────────
program program

View File

@@ -0,0 +1,754 @@
/**
* Native runtime launcher — replaces the bash mosaic-launch script.
*
* Builds a composed runtime prompt from AGENTS.md + RUNTIME.md + USER.md +
* TOOLS.md + mission context + PRD status, then exec's into the target CLI.
*/
import { execFileSync, execSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
import type { Command } from 'commander';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
const RUNTIME_LABELS: Record<RuntimeName, string> = {
claude: 'Claude Code',
codex: 'Codex',
opencode: 'OpenCode',
pi: 'Pi',
};
// ─── Pre-flight checks ──────────────────────────────────────────────────────
function checkMosaicHome(): void {
if (!existsSync(MOSAIC_HOME)) {
console.error(`[mosaic] ERROR: ${MOSAIC_HOME} not found.`);
console.error(
'[mosaic] Install: bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)',
);
process.exit(1);
}
}
function checkFile(path: string, label: string): void {
if (!existsSync(path)) {
console.error(`[mosaic] ERROR: ${label} not found: ${path}`);
process.exit(1);
}
}
function checkRuntime(cmd: string): void {
try {
execSync(`which ${cmd}`, { stdio: 'ignore' });
} catch {
console.error(`[mosaic] ERROR: '${cmd}' not found in PATH.`);
console.error(`[mosaic] Install ${cmd} before launching.`);
process.exit(1);
}
}
function checkSoul(): void {
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
if (!existsSync(soulPath)) {
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
// Prefer the TypeScript wizard (idempotent, detects existing files)
try {
const result = spawnSync(process.execPath, [process.argv[1]!, 'wizard'], {
stdio: 'inherit',
});
if (result.status === 0 && existsSync(soulPath)) return;
} catch {
// Fall through to legacy init
}
// Fallback: legacy bash mosaic-init
const initBin = join(MOSAIC_HOME, 'tools', '_scripts', 'mosaic-init');
if (existsSync(initBin)) {
spawnSync(initBin, [], { stdio: 'inherit' });
} else {
console.error('[mosaic] Setup failed. Run: mosaic wizard');
process.exit(1);
}
}
}
function checkSequentialThinking(runtime: string): void {
const checker = join(MOSAIC_HOME, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
if (!existsSync(checker)) return; // Skip if checker doesn't exist
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
if (result.status !== 0) {
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}`);
process.exit(1);
}
}
// ─── File helpers ────────────────────────────────────────────────────────────
function readOptional(path: string): string {
try {
return readFileSync(path, 'utf-8');
} catch {
return '';
}
}
function readJson(path: string): Record<string, unknown> | null {
try {
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, unknown>;
} catch {
return null;
}
}
// ─── Mission context ─────────────────────────────────────────────────────────
interface MissionInfo {
name: string;
id: string;
status: string;
milestoneCount: number;
completedCount: number;
}
function detectMission(): MissionInfo | null {
const missionFile = '.mosaic/orchestrator/mission.json';
const data = readJson(missionFile);
if (!data) return null;
const status = String(data['status'] ?? 'inactive');
if (status !== 'active' && status !== 'paused') return null;
const milestones = Array.isArray(data['milestones']) ? data['milestones'] : [];
const completed = milestones.filter(
(m) =>
typeof m === 'object' &&
m !== null &&
(m as Record<string, unknown>)['status'] === 'completed',
);
return {
name: String(data['name'] ?? 'unnamed'),
id: String(data['mission_id'] ?? ''),
status,
milestoneCount: milestones.length,
completedCount: completed.length,
};
}
function buildMissionBlock(mission: MissionInfo): string {
return `# ACTIVE MISSION — HARD GATE (Read Before Anything Else)
An active orchestration mission exists in this project. This is a BLOCKING requirement.
**Mission:** ${mission.name}
**ID:** ${mission.id}
**Status:** ${mission.status}
**Milestones:** ${mission.completedCount} / ${mission.milestoneCount} completed
## MANDATORY — Before ANY Response to the User
You MUST complete these steps before responding to any user message, including simple greetings:
1. Read \`~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md\` (mission lifecycle protocol)
2. Read \`docs/MISSION-MANIFEST.md\` for full mission scope, milestones, and success criteria
3. Read the latest scratchpad in \`docs/scratchpads/\` for session history, decisions, and corrections
4. Read \`docs/TASKS.md\` for current task state (what is done, what is next)
5. After reading all four, acknowledge the mission state to the user before proceeding
If the user gives a task, execute it within the mission context. If no task is given, present mission status and ask how to proceed.
`;
}
// ─── PRD status ──────────────────────────────────────────────────────────────
function buildPrdBlock(): string {
const prdFile = 'docs/PRD.md';
if (!existsSync(prdFile)) return '';
const content = readFileSync(prdFile, 'utf-8');
const patterns = [
/^#{2,3} .*(problem statement|objective)/im,
/^#{2,3} .*(scope|non.goal|out of scope|in.scope)/im,
/^#{2,3} .*(user stor|stakeholder|user.*requirement)/im,
/^#{2,3} .*functional requirement/im,
/^#{2,3} .*non.functional/im,
/^#{2,3} .*acceptance criteria/im,
/^#{2,3} .*(technical consideration|constraint|dependenc)/im,
/^#{2,3} .*(risk|open question)/im,
/^#{2,3} .*(success metric|test|verification)/im,
/^#{2,3} .*(milestone|delivery|scope version)/im,
];
let sections = 0;
for (const pattern of patterns) {
if (pattern.test(content)) sections++;
}
const assumptions = (content.match(/ASSUMPTION:/g) ?? []).length;
const status = sections < 10 ? `incomplete (${sections}/10 sections)` : 'ready';
return `
# PRD Status
- **File:** docs/PRD.md
- **Status:** ${status}
- **Assumptions:** ${assumptions}
`;
}
// ─── Runtime prompt builder ──────────────────────────────────────────────────
function buildRuntimePrompt(runtime: RuntimeName): string {
const runtimeContractPaths: Record<RuntimeName, string> = {
claude: join(MOSAIC_HOME, 'runtime', 'claude', 'RUNTIME.md'),
codex: join(MOSAIC_HOME, 'runtime', 'codex', 'RUNTIME.md'),
opencode: join(MOSAIC_HOME, 'runtime', 'opencode', 'RUNTIME.md'),
pi: join(MOSAIC_HOME, 'runtime', 'pi', 'RUNTIME.md'),
};
const runtimeFile = runtimeContractPaths[runtime];
checkFile(runtimeFile, `Runtime contract for ${runtime}`);
const parts: string[] = [];
// Mission context (injected first)
const mission = detectMission();
if (mission) {
parts.push(buildMissionBlock(mission));
}
// PRD status
const prdBlock = buildPrdBlock();
if (prdBlock) parts.push(prdBlock);
// Hard gate
parts.push(`# Mosaic Launcher Runtime Contract (Hard Gate)
This contract is injected by \`mosaic\` launch and is mandatory.
First assistant response MUST start with exactly one mode declaration line:
1. Orchestration mission: \`Now initiating Orchestrator mode...\`
2. Implementation mission: \`Now initiating Delivery mode...\`
3. Review-only mission: \`Now initiating Review mode...\`
No tool call or implementation step may occur before that first line.
Mosaic hard gates OVERRIDE runtime-default caution for routine delivery operations.
For required push/merge/issue-close/release actions, execute without routine confirmation prompts.
`);
// AGENTS.md
parts.push(readFileSync(join(MOSAIC_HOME, 'AGENTS.md'), 'utf-8'));
// USER.md
const user = readOptional(join(MOSAIC_HOME, 'USER.md'));
if (user) parts.push('\n\n# User Profile\n\n' + user);
// TOOLS.md
const tools = readOptional(join(MOSAIC_HOME, 'TOOLS.md'));
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
// Runtime-specific contract
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
return parts.join('\n');
}
// ─── Session lock ────────────────────────────────────────────────────────────
function writeSessionLock(runtime: string): void {
const missionFile = '.mosaic/orchestrator/mission.json';
const lockFile = '.mosaic/orchestrator/session.lock';
const data = readJson(missionFile);
if (!data) return;
const status = String(data['status'] ?? 'inactive');
if (status !== 'active' && status !== 'paused') return;
const sessionId = `${runtime}-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`;
const lock = {
session_id: sessionId,
runtime,
pid: process.pid,
started_at: new Date().toISOString(),
project_path: process.cwd(),
milestone_id: '',
};
try {
mkdirSync(dirname(lockFile), { recursive: true });
writeFileSync(lockFile, JSON.stringify(lock, null, 2) + '\n');
// Clean up on exit
const cleanup = () => {
try {
rmSync(lockFile, { force: true });
} catch {
// best-effort
}
};
process.on('exit', cleanup);
process.on('SIGINT', () => {
cleanup();
process.exit(130);
});
process.on('SIGTERM', () => {
cleanup();
process.exit(143);
});
} catch {
// Non-fatal
}
}
// ─── Resumable session advisory ──────────────────────────────────────────────
function checkResumableSession(): void {
const lockFile = '.mosaic/orchestrator/session.lock';
const missionFile = '.mosaic/orchestrator/mission.json';
if (existsSync(lockFile)) {
const lock = readJson(lockFile);
if (lock) {
const pid = Number(lock['pid'] ?? 0);
if (pid > 0) {
try {
process.kill(pid, 0); // Check if alive
} catch {
// Process is dead — stale lock
rmSync(lockFile, { force: true });
console.log(`[mosaic] Cleaned up stale session lock (PID ${pid} no longer running).\n`);
}
}
}
} else if (existsSync(missionFile)) {
const data = readJson(missionFile);
if (data && data['status'] === 'active') {
console.log('[mosaic] Active mission detected. Generate continuation prompt with:');
console.log('[mosaic] mosaic coord continue\n');
}
}
}
// ─── Write config for runtimes that read from fixed paths ────────────────────
function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void {
const prompt = buildRuntimePrompt(runtime);
mkdirSync(dirname(destPath), { recursive: true });
const existing = readOptional(destPath);
if (existing !== prompt) {
writeFileSync(destPath, prompt);
}
}
// ─── Pi skill/extension discovery ────────────────────────────────────────────
function discoverPiSkills(): string[] {
const args: string[] = [];
for (const skillsRoot of [join(MOSAIC_HOME, 'skills'), join(MOSAIC_HOME, 'skills-local')]) {
if (!existsSync(skillsRoot)) continue;
try {
for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const skillDir = join(skillsRoot, entry.name);
if (existsSync(join(skillDir, 'SKILL.md'))) {
args.push('--skill', skillDir);
}
}
} catch {
// skip
}
}
return args;
}
function discoverPiExtension(): string[] {
const ext = join(MOSAIC_HOME, 'runtime', 'pi', 'mosaic-extension.ts');
return existsSync(ext) ? ['--extension', ext] : [];
}
// ─── Launch functions ────────────────────────────────────────────────────────
function getMissionPrompt(): string {
const mission = detectMission();
if (!mission) return '';
return `Active mission detected: ${mission.name}. Read the mission state files and report status.`;
}
function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): never {
checkMosaicHome();
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
checkSoul();
checkRuntime(runtime);
// Pi doesn't need sequential-thinking (has native thinking levels)
if (runtime !== 'pi') {
checkSequentialThinking(runtime);
}
checkResumableSession();
const missionPrompt = getMissionPrompt();
const hasMissionNoArgs = missionPrompt && args.length === 0;
const label = RUNTIME_LABELS[runtime];
const modeStr = yolo ? ' in YOLO mode' : '';
const missionStr = hasMissionNoArgs ? ' (active mission detected)' : '';
writeSessionLock(runtime);
switch (runtime) {
case 'claude': {
const prompt = buildRuntimePrompt('claude');
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
cliArgs.push('--append-system-prompt', prompt);
if (hasMissionNoArgs) {
cliArgs.push(missionPrompt);
} else {
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
execRuntime('claude', cliArgs);
break;
}
case 'codex': {
ensureRuntimeConfig('codex', join(homedir(), '.codex', 'instructions.md'));
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
if (hasMissionNoArgs) {
cliArgs.push(missionPrompt);
} else {
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
execRuntime('codex', cliArgs);
break;
}
case 'opencode': {
ensureRuntimeConfig('opencode', join(homedir(), '.config', 'opencode', 'AGENTS.md'));
console.log(`[mosaic] Launching ${label}${modeStr}...`);
execRuntime('opencode', args);
break;
}
case 'pi': {
const prompt = buildRuntimePrompt('pi');
const cliArgs = ['--append-system-prompt', prompt];
cliArgs.push(...discoverPiSkills());
cliArgs.push(...discoverPiExtension());
if (hasMissionNoArgs) {
cliArgs.push(missionPrompt);
} else {
cliArgs.push(...args);
}
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
execRuntime('pi', cliArgs);
break;
}
}
process.exit(0); // Unreachable but satisfies never
}
/** exec into the runtime, replacing the current process. */
function execRuntime(cmd: string, args: string[]): void {
try {
// Use execFileSync with inherited stdio to replace the process
const result = spawnSync(cmd, args, {
stdio: 'inherit',
env: process.env,
});
process.exit(result.status ?? 0);
} catch (err) {
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
process.exit(1);
}
}
// ─── Framework script/tool delegation ───────────────────────────────────────
function delegateToScript(scriptPath: string, args: string[], env?: Record<string, string>): never {
if (!existsSync(scriptPath)) {
console.error(`[mosaic] Script not found: ${scriptPath}`);
process.exit(1);
}
try {
execFileSync('bash', [scriptPath, ...args], {
stdio: 'inherit',
env: { ...process.env, ...env },
});
process.exit(0);
} catch (err) {
process.exit((err as { status?: number }).status ?? 1);
}
}
function fwScript(name: string): string {
return join(MOSAIC_HOME, 'tools', '_scripts', name);
}
function toolScript(toolDir: string, name: string): string {
return join(MOSAIC_HOME, 'tools', toolDir, name);
}
// ─── Coord (mission orchestrator) ───────────────────────────────────────────
const COORD_SUBCMDS: Record<string, string> = {
status: 'session-status.sh',
session: 'session-status.sh',
init: 'mission-init.sh',
mission: 'mission-status.sh',
progress: 'mission-status.sh',
continue: 'continue-prompt.sh',
next: 'continue-prompt.sh',
run: 'session-run.sh',
start: 'session-run.sh',
smoke: 'smoke-test.sh',
test: 'smoke-test.sh',
resume: 'session-resume.sh',
recover: 'session-resume.sh',
};
function runCoord(args: string[]): never {
checkMosaicHome();
let runtime = 'claude';
let yoloFlag = '';
const coordArgs: string[] = [];
for (const arg of args) {
if (arg === '--claude' || arg === '--codex' || arg === '--pi') {
runtime = arg.slice(2);
} else if (arg === '--yolo') {
yoloFlag = '--yolo';
} else {
coordArgs.push(arg);
}
}
const subcmd = coordArgs[0] ?? 'help';
const subArgs = coordArgs.slice(1);
const script = COORD_SUBCMDS[subcmd];
if (!script) {
console.log(`mosaic coord — mission coordinator tools
Commands:
init --name <name> [opts] Initialize a new mission
mission [--project <path>] Show mission progress dashboard
status [--project <path>] Check agent session health
continue [--project <path>] Generate continuation prompt
run [--project <path>] Launch runtime with mission context
smoke Run orchestration smoke checks
resume [--project <path>] Crash recovery
Runtime: --claude (default) | --codex | --pi | --yolo`);
process.exit(subcmd === 'help' ? 0 : 1);
}
if (yoloFlag) subArgs.unshift(yoloFlag);
delegateToScript(toolScript('orchestrator', script), subArgs, {
MOSAIC_COORD_RUNTIME: runtime,
});
}
// ─── Prdy (PRD tools via framework scripts) ─────────────────────────────────
const PRDY_SUBCMDS: Record<string, string> = {
init: 'prdy-init.sh',
update: 'prdy-update.sh',
validate: 'prdy-validate.sh',
check: 'prdy-validate.sh',
status: 'prdy-status.sh',
};
function runPrdyLocal(args: string[]): never {
checkMosaicHome();
let runtime = 'claude';
const prdyArgs: string[] = [];
for (const arg of args) {
if (arg === '--claude' || arg === '--codex' || arg === '--pi') {
runtime = arg.slice(2);
} else {
prdyArgs.push(arg);
}
}
const subcmd = prdyArgs[0] ?? 'help';
const subArgs = prdyArgs.slice(1);
const script = PRDY_SUBCMDS[subcmd];
if (!script) {
console.log(`mosaic prdy — PRD creation and validation
Commands:
init [--project <path>] [--name <feature>] Create docs/PRD.md
update [--project <path>] Update existing PRD
validate [--project <path>] Check PRD completeness
status [--project <path>] Quick PRD health check
Runtime: --claude (default) | --codex | --pi`);
process.exit(subcmd === 'help' ? 0 : 1);
}
delegateToScript(toolScript('prdy', script), subArgs, {
MOSAIC_PRDY_RUNTIME: runtime,
});
}
// ─── Seq (sequential-thinking MCP) ──────────────────────────────────────────
function runSeq(args: string[]): never {
checkMosaicHome();
const action = args[0] ?? 'check';
const rest = args.slice(1);
const checker = fwScript('mosaic-ensure-sequential-thinking');
switch (action) {
case 'check':
delegateToScript(checker, ['--check', ...rest]);
break; // unreachable
case 'fix':
case 'apply':
delegateToScript(checker, rest);
break;
case 'start': {
console.log('[mosaic] Starting sequential-thinking MCP server...');
try {
execFileSync('npx', ['-y', '@modelcontextprotocol/server-sequential-thinking', ...rest], {
stdio: 'inherit',
});
process.exit(0);
} catch (err) {
process.exit((err as { status?: number }).status ?? 1);
}
break;
}
default:
console.error(`[mosaic] Unknown seq subcommand '${action}'. Use: check|fix|start`);
process.exit(1);
}
}
// ─── Upgrade ────────────────────────────────────────────────────────────────
function runUpgrade(args: string[]): never {
checkMosaicHome();
const subcmd = args[0];
if (!subcmd || subcmd === 'release') {
delegateToScript(fwScript('mosaic-release-upgrade'), args.slice(subcmd === 'release' ? 1 : 0));
} else if (subcmd === 'check') {
delegateToScript(fwScript('mosaic-release-upgrade'), ['--dry-run', ...args.slice(1)]);
} else if (subcmd === 'project') {
delegateToScript(fwScript('mosaic-upgrade'), args.slice(1));
} else if (subcmd.startsWith('-')) {
delegateToScript(fwScript('mosaic-release-upgrade'), args);
} else {
delegateToScript(fwScript('mosaic-upgrade'), args);
}
}
// ─── Commander registration ─────────────────────────────────────────────────
export function registerLaunchCommands(program: Command): void {
// Runtime launchers
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
program
.command(runtime)
.description(`Launch ${RUNTIME_LABELS[runtime]} with Mosaic injection`)
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
launchRuntime(runtime, cmd.args, false);
});
}
// Yolo mode
program
.command('yolo <runtime>')
.description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((runtime: string, _opts: unknown, cmd: Command) => {
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
if (!valid.includes(runtime as RuntimeName)) {
console.error(
`[mosaic] ERROR: Unsupported yolo runtime '${runtime}'. Use: ${valid.join('|')}`,
);
process.exit(1);
}
launchRuntime(runtime as RuntimeName, cmd.args, true);
});
// Coord (mission orchestrator)
program
.command('coord')
.description('Mission coordinator tools (init, status, run, continue, resume)')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
runCoord(cmd.args);
});
// Prdy (PRD tools via local framework scripts)
program
.command('prdy')
.description('PRD creation and validation (init, update, validate, status)')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
runPrdyLocal(cmd.args);
});
// Seq (sequential-thinking MCP management)
program
.command('seq')
.description('sequential-thinking MCP management (check/fix/start)')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
runSeq(cmd.args);
});
// Upgrade (release + project)
program
.command('upgrade')
.description('Upgrade Mosaic release or project files')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
runUpgrade(cmd.args);
});
// Direct framework script delegates
const directCommands: Record<string, { desc: string; script: string }> = {
init: { desc: 'Generate SOUL.md (agent identity contract)', script: 'mosaic-init' },
doctor: { desc: 'Health audit — detect drift and missing files', script: 'mosaic-doctor' },
sync: { desc: 'Sync skills from canonical source', script: 'mosaic-sync-skills' },
bootstrap: {
desc: 'Bootstrap a repo with Mosaic standards',
script: 'mosaic-bootstrap-repo',
},
};
for (const [name, { desc, script }] of Object.entries(directCommands)) {
program
.command(name)
.description(desc)
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => {
checkMosaicHome();
delegateToScript(fwScript(script), cmd.args);
});
}
}

View File

@@ -15,6 +15,7 @@ import { useConversations } from './hooks/use-conversations.js';
import { useSearch } from './hooks/use-search.js'; import { useSearch } from './hooks/use-search.js';
import { executeHelp, executeStatus, executeHistory, commandRegistry } from './commands/index.js'; import { executeHelp, executeStatus, executeHistory, commandRegistry } from './commands/index.js';
import { fetchConversationMessages } from './gateway-api.js'; import { fetchConversationMessages } from './gateway-api.js';
import { expandFileRefs, hasFileRefs, handleAttachCommand } from './file-ref.js';
export interface TuiAppProps { export interface TuiAppProps {
gatewayUrl: string; gatewayUrl: string;
@@ -85,6 +86,36 @@ export function TuiApp({
// combo is handled by the top-level useInput handler (e.g. Ctrl+T → 't'). // combo is handled by the top-level useInput handler (e.g. Ctrl+T → 't').
const ctrlJustFired = useRef(false); const ctrlJustFired = useRef(false);
// Wrap sendMessage to expand @file references before sending
const sendMessageWithFileRefs = useCallback(
(content: string) => {
if (!hasFileRefs(content)) {
socket.sendMessage(content);
return;
}
void expandFileRefs(content)
.then(({ expandedMessage, filesAttached, errors }) => {
for (const err of errors) {
socket.addSystemMessage(err);
}
if (filesAttached.length > 0) {
socket.addSystemMessage(
`📎 Attached ${filesAttached.length} file(s): ${filesAttached.join(', ')}`,
);
}
socket.sendMessage(expandedMessage);
})
.catch((err: unknown) => {
socket.addSystemMessage(
`File expansion failed: ${err instanceof Error ? err.message : String(err)}`,
);
// Send original message without expansion
socket.sendMessage(content);
});
},
[socket],
);
const handleLocalCommand = useCallback( const handleLocalCommand = useCallback(
(parsed: ParsedCommand) => { (parsed: ParsedCommand) => {
switch (parsed.command) { switch (parsed.command) {
@@ -123,9 +154,36 @@ export function TuiApp({
socket.addSystemMessage('Failed to create new conversation.'); socket.addSystemMessage('Failed to create new conversation.');
}); });
break; break;
case 'attach': {
if (!parsed.args) {
socket.addSystemMessage('Usage: /attach <file-path>');
break;
}
void handleAttachCommand(parsed.args)
.then(({ content, error }) => {
if (error) {
socket.addSystemMessage(`Attach error: ${error}`);
} else if (content) {
// Send the file content as a user message
socket.sendMessage(content);
}
})
.catch((err: unknown) => {
socket.addSystemMessage(
`Attach failed: ${err instanceof Error ? err.message : String(err)}`,
);
});
break;
}
case 'stop': case 'stop':
// Currently no stop mechanism exposed — show feedback if (socket.isStreaming && socket.socketRef.current?.connected && socket.conversationId) {
socket.addSystemMessage('Stop is not available for the current session.'); socket.socketRef.current.emit('abort', {
conversationId: socket.conversationId,
});
socket.addSystemMessage('Abort signal sent.');
} else {
socket.addSystemMessage('No active stream to stop.');
}
break; break;
case 'cost': { case 'cost': {
const u = socket.tokenUsage; const u = socket.tokenUsage;
@@ -348,7 +406,7 @@ export function TuiApp({
} }
setTuiInput(val); setTuiInput(val);
}} }}
onSubmit={socket.sendMessage} onSubmit={sendMessageWithFileRefs}
onSystemMessage={socket.addSystemMessage} onSystemMessage={socket.addSystemMessage}
onLocalCommand={handleLocalCommand} onLocalCommand={handleLocalCommand}
onGatewayCommand={handleGatewayCommand} onGatewayCommand={handleGatewayCommand}

View File

@@ -56,6 +56,22 @@ const LOCAL_COMMANDS: CommandDef[] = [
available: true, available: true,
scope: 'core', scope: 'core',
}, },
{
name: 'attach',
description: 'Attach a file to the next message (@file syntax also works inline)',
aliases: [],
args: [
{
name: 'path',
type: 'string' as const,
optional: false,
description: 'File path to attach',
},
],
execution: 'local',
available: true,
scope: 'core',
},
{ {
name: 'new', name: 'new',
description: 'Start a new conversation', description: 'Start a new conversation',

View File

@@ -0,0 +1,202 @@
/**
* File reference expansion for TUI chat input.
*
* Detects @path/to/file patterns in user messages, reads the file contents,
* and inlines them as fenced code blocks in the message.
*
* Supports:
* - @relative/path.ts
* - @./relative/path.ts
* - @/absolute/path.ts
* - @~/home-relative/path.ts
*
* Also provides an /attach <path> command handler.
*/
import { readFile, stat } from 'node:fs/promises';
import { resolve, extname, basename } from 'node:path';
import { homedir } from 'node:os';
const MAX_FILE_SIZE = 256 * 1024; // 256 KB
const MAX_FILES_PER_MESSAGE = 10;
/**
* Regex to detect @file references in user input.
* Matches @<path> where path starts with /, ./, ~/, or a word char,
* and continues until whitespace or end of string.
* Excludes @mentions that look like usernames (no dots/slashes).
*/
const FILE_REF_PATTERN = /(?:^|\s)@((?:\.{0,2}\/|~\/|[a-zA-Z0-9_])[^\s]+)/g;
interface FileRefResult {
/** The expanded message text with file contents inlined */
expandedMessage: string;
/** Files that were successfully read */
filesAttached: string[];
/** Errors encountered while reading files */
errors: string[];
}
function resolveFilePath(ref: string): string {
if (ref.startsWith('~/')) {
return resolve(homedir(), ref.slice(2));
}
return resolve(process.cwd(), ref);
}
function getLanguageHint(filePath: string): string {
const ext = extname(filePath).toLowerCase();
const map: Record<string, string> = {
'.ts': 'typescript',
'.tsx': 'typescript',
'.js': 'javascript',
'.jsx': 'javascript',
'.py': 'python',
'.rb': 'ruby',
'.rs': 'rust',
'.go': 'go',
'.java': 'java',
'.c': 'c',
'.cpp': 'cpp',
'.h': 'c',
'.hpp': 'cpp',
'.cs': 'csharp',
'.sh': 'bash',
'.bash': 'bash',
'.zsh': 'zsh',
'.fish': 'fish',
'.json': 'json',
'.yaml': 'yaml',
'.yml': 'yaml',
'.toml': 'toml',
'.xml': 'xml',
'.html': 'html',
'.css': 'css',
'.scss': 'scss',
'.md': 'markdown',
'.sql': 'sql',
'.graphql': 'graphql',
'.dockerfile': 'dockerfile',
'.tf': 'terraform',
'.vue': 'vue',
'.svelte': 'svelte',
};
return map[ext] ?? '';
}
/**
* Check if the input contains any @file references.
*/
export function hasFileRefs(input: string): boolean {
FILE_REF_PATTERN.lastIndex = 0;
return FILE_REF_PATTERN.test(input);
}
/**
* Expand @file references in a message by reading file contents
* and appending them as fenced code blocks.
*/
export async function expandFileRefs(input: string): Promise<FileRefResult> {
const refs: string[] = [];
FILE_REF_PATTERN.lastIndex = 0;
let match;
while ((match = FILE_REF_PATTERN.exec(input)) !== null) {
const ref = match[1]!;
if (!refs.includes(ref)) {
refs.push(ref);
}
}
if (refs.length === 0) {
return { expandedMessage: input, filesAttached: [], errors: [] };
}
if (refs.length > MAX_FILES_PER_MESSAGE) {
return {
expandedMessage: input,
filesAttached: [],
errors: [`Too many file references (${refs.length}). Maximum is ${MAX_FILES_PER_MESSAGE}.`],
};
}
const filesAttached: string[] = [];
const errors: string[] = [];
const attachments: string[] = [];
for (const ref of refs) {
const filePath = resolveFilePath(ref);
try {
const info = await stat(filePath);
if (!info.isFile()) {
errors.push(`@${ref}: not a file`);
continue;
}
if (info.size > MAX_FILE_SIZE) {
errors.push(
`@${ref}: file too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`,
);
continue;
}
const content = await readFile(filePath, 'utf8');
const lang = getLanguageHint(filePath);
const name = basename(filePath);
attachments.push(`\n📎 ${ref} (${name}):\n\`\`\`${lang}\n${content}\n\`\`\``);
filesAttached.push(ref);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// Only report meaningful errors — ENOENT is common for false @mention matches
if (msg.includes('ENOENT')) {
// Check if this looks like a file path (has extension or slash)
if (ref.includes('/') || ref.includes('.')) {
errors.push(`@${ref}: file not found`);
}
// Otherwise silently skip — likely an @mention, not a file ref
} else {
errors.push(`@${ref}: ${msg}`);
}
}
}
if (attachments.length === 0) {
return { expandedMessage: input, filesAttached, errors };
}
const expandedMessage = input + '\n' + attachments.join('\n');
return { expandedMessage, filesAttached, errors };
}
/**
* Handle the /attach <path> command.
* Reads a file and returns the content formatted for inclusion in the chat.
*/
export async function handleAttachCommand(
args: string,
): Promise<{ content: string; error?: string }> {
const filePath = args.trim();
if (!filePath) {
return { content: '', error: 'Usage: /attach <file-path>' };
}
const resolved = resolveFilePath(filePath);
try {
const info = await stat(resolved);
if (!info.isFile()) {
return { content: '', error: `Not a file: ${filePath}` };
}
if (info.size > MAX_FILE_SIZE) {
return {
content: '',
error: `File too large (${(info.size / 1024).toFixed(0)} KB, limit ${MAX_FILE_SIZE / 1024} KB)`,
};
}
const content = await readFile(resolved, 'utf8');
const lang = getLanguageHint(resolved);
const name = basename(resolved);
return {
content: `📎 Attached file: ${name} (${filePath})\n\`\`\`${lang}\n${content}\n\`\`\``,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { content: '', error: `Failed to read file: ${msg}` };
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.spec.ts"]
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/coord", "name": "@mosaic/coord",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { "exports": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/db", "name": "@mosaic/db",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/design-tokens", "name": "@mosaic/design-tokens",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/forge", "name": "@mosaic/forge",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/log", "name": "@mosaic/log",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/macp", "name": "@mosaic/macp",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/memory", "name": "@mosaic/memory",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -20,10 +20,13 @@ describe('Full Wizard (headless)', () => {
beforeEach(() => { beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-')); tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-'));
// Copy templates to tmp dir // Copy templates to tmp dir — templates live under framework/ after monorepo migration
const templatesDir = join(repoRoot, 'templates'); const candidates = [join(repoRoot, 'framework', 'templates'), join(repoRoot, 'templates')];
if (existsSync(templatesDir)) { for (const templatesDir of candidates) {
cpSync(templatesDir, join(tmpDir, 'templates'), { recursive: true }); if (existsSync(templatesDir)) {
cpSync(templatesDir, join(tmpDir, 'templates'), { recursive: true });
break;
}
} }
}); });

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
mkdtempSync,
mkdirSync,
writeFileSync,
readFileSync,
existsSync,
chmodSync,
rmSync,
} from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { syncDirectory } from '../../src/platform/file-ops.js';
describe('syncDirectory', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-file-ops-'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('is a no-op when source and target are the same path', () => {
const dir = join(tmpDir, 'same');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'file.txt'), 'hello');
// Should not throw even with read-only files
const gitDir = join(dir, '.git', 'objects', 'pack');
mkdirSync(gitDir, { recursive: true });
const packFile = join(gitDir, 'pack-abc.idx');
writeFileSync(packFile, 'data');
chmodSync(packFile, 0o444);
expect(() => syncDirectory(dir, dir)).not.toThrow();
});
it('skips nested .git directories when excludeGit is true', () => {
const src = join(tmpDir, 'src');
const dest = join(tmpDir, 'dest');
// Create source with a nested .git
mkdirSync(join(src, 'sources', 'skills', '.git', 'objects'), { recursive: true });
writeFileSync(join(src, 'sources', 'skills', '.git', 'objects', 'pack.idx'), 'git-data');
writeFileSync(join(src, 'sources', 'skills', 'SKILL.md'), 'skill content');
writeFileSync(join(src, 'README.md'), 'readme');
syncDirectory(src, dest, { excludeGit: true });
// .git contents should NOT be copied
expect(existsSync(join(dest, 'sources', 'skills', '.git'))).toBe(false);
// Normal files should be copied
expect(readFileSync(join(dest, 'sources', 'skills', 'SKILL.md'), 'utf-8')).toBe(
'skill content',
);
expect(readFileSync(join(dest, 'README.md'), 'utf-8')).toBe('readme');
});
it('copies nested .git directories when excludeGit is false', () => {
const src = join(tmpDir, 'src');
const dest = join(tmpDir, 'dest');
mkdirSync(join(src, 'sub', '.git'), { recursive: true });
writeFileSync(join(src, 'sub', '.git', 'HEAD'), 'ref: refs/heads/main');
syncDirectory(src, dest, { excludeGit: false });
expect(readFileSync(join(dest, 'sub', '.git', 'HEAD'), 'utf-8')).toBe('ref: refs/heads/main');
});
it('respects preserve option', () => {
const src = join(tmpDir, 'src');
const dest = join(tmpDir, 'dest');
mkdirSync(src, { recursive: true });
mkdirSync(dest, { recursive: true });
writeFileSync(join(src, 'SOUL.md'), 'new soul');
writeFileSync(join(dest, 'SOUL.md'), 'old soul');
writeFileSync(join(src, 'README.md'), 'new readme');
syncDirectory(src, dest, { preserve: ['SOUL.md'] });
expect(readFileSync(join(dest, 'SOUL.md'), 'utf-8')).toBe('old soul');
expect(readFileSync(join(dest, 'README.md'), 'utf-8')).toBe('new readme');
});
});

View File

@@ -65,4 +65,36 @@ describe('detectInstallStage', () => {
expect(state.installAction).toBe('keep'); expect(state.installAction).toBe('keep');
expect(state.soul.agentName).toBe('TestAgent'); expect(state.soul.agentName).toBe('TestAgent');
}); });
it('pre-populates state when reconfiguring', async () => {
mkdirSync(join(tmpDir, 'bin'), { recursive: true });
writeFileSync(join(tmpDir, 'SOUL.md'), 'You are **Jarvis** in this session.');
writeFileSync(join(tmpDir, 'USER.md'), '**Name:** TestUser');
const p = new HeadlessPrompter({
'What would you like to do?': 'reconfigure',
});
const state = createState(tmpDir);
await detectInstallStage(p, state, mockConfig);
expect(state.installAction).toBe('reconfigure');
// Existing values loaded as defaults for reconfiguration
expect(state.soul.agentName).toBe('TestAgent');
expect(state.user.userName).toBe('TestUser');
});
it('does not pre-populate state on fresh reset', async () => {
mkdirSync(join(tmpDir, 'bin'), { recursive: true });
writeFileSync(join(tmpDir, 'SOUL.md'), 'You are **Jarvis** in this session.');
const p = new HeadlessPrompter({
'What would you like to do?': 'reset',
});
const state = createState(tmpDir);
await detectInstallStage(p, state, mockConfig);
expect(state.installAction).toBe('reset');
// Reset should NOT load existing values
expect(state.soul.agentName).toBeUndefined();
});
}); });

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { semverLt, formatUpdateNotice } from '../src/runtime/update-checker.js';
import type { UpdateCheckResult } from '../src/runtime/update-checker.js';
describe('semverLt', () => {
it('returns true when a < b', () => {
expect(semverLt('0.0.1', '0.0.2')).toBe(true);
expect(semverLt('0.1.0', '0.2.0')).toBe(true);
expect(semverLt('1.0.0', '2.0.0')).toBe(true);
expect(semverLt('0.0.1-alpha.1', '0.0.1-alpha.2')).toBe(true);
expect(semverLt('0.0.1-alpha.1', '0.0.1')).toBe(true);
});
it('returns false when a >= b', () => {
expect(semverLt('0.0.2', '0.0.1')).toBe(false);
expect(semverLt('1.0.0', '1.0.0')).toBe(false);
expect(semverLt('2.0.0', '1.0.0')).toBe(false);
});
it('returns false for empty strings', () => {
expect(semverLt('', '1.0.0')).toBe(false);
expect(semverLt('1.0.0', '')).toBe(false);
expect(semverLt('', '')).toBe(false);
});
});
describe('formatUpdateNotice', () => {
it('returns empty string when up to date', () => {
const result: UpdateCheckResult = {
current: '1.0.0',
latest: '1.0.0',
updateAvailable: false,
checkedAt: new Date().toISOString(),
registry: 'https://example.com',
};
expect(formatUpdateNotice(result)).toBe('');
});
it('returns a notice when update is available', () => {
const result: UpdateCheckResult = {
current: '0.0.1',
latest: '0.1.0',
updateAvailable: true,
checkedAt: new Date().toISOString(),
registry: 'https://example.com',
};
const notice = formatUpdateNotice(result);
expect(notice).toContain('0.0.1');
expect(notice).toContain('0.1.0');
expect(notice).toContain('Update available');
});
});

View File

@@ -1,849 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# mosaic — Unified agent launcher and management CLI
#
# AGENTS.md is the global policy source for all agent sessions.
# The launcher injects a composed runtime contract (AGENTS + runtime reference).
#
# Usage:
# mosaic claude [args...] Launch Claude Code with runtime contract injected
# mosaic opencode [args...] Launch OpenCode with runtime contract injected
# mosaic codex [args...] Launch Codex with runtime contract injected
# mosaic yolo <runtime> [args...] Launch runtime in dangerous-permissions mode
# mosaic --yolo <runtime> [args...] Alias for yolo
# mosaic init [args...] Generate SOUL.md interactively
# mosaic doctor [args...] Health audit
# mosaic sync [args...] Sync skills
# mosaic seq [subcommand] sequential-thinking MCP management (check/fix/start)
# mosaic bootstrap <path> Bootstrap a repo
# mosaic upgrade release Upgrade installed Mosaic release
# mosaic upgrade check Check release upgrade status (no changes)
# mosaic upgrade project [args] Upgrade project-local stale files
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
VERSION="0.1.0"
usage() {
cat <<USAGE
mosaic $VERSION — Unified agent launcher
Usage: mosaic <command> [args...]
Agent Launchers:
pi [args...] Launch Pi with runtime contract injected (recommended)
claude [args...] Launch Claude Code with runtime contract injected
opencode [args...] Launch OpenCode with runtime contract injected
codex [args...] Launch Codex with runtime contract injected
yolo <runtime> [args...] Dangerous mode for claude|codex|opencode|pi
--yolo <runtime> [args...] Alias for yolo
Management:
init [args...] Generate SOUL.md (agent identity contract)
doctor [args...] Audit runtime state and detect drift
sync [args...] Sync skills from canonical source
seq [subcommand] sequential-thinking MCP management:
check [--runtime <r>] [--strict]
fix [--runtime <r>]
start
bootstrap <path> Bootstrap a repo with Mosaic standards
upgrade [mode] [args] Upgrade release (default) or project files
upgrade check Check release upgrade status (no changes)
release-upgrade [...] Upgrade installed Mosaic release
project-upgrade [...] Clean up stale SOUL.md/CLAUDE.md in a project
PRD:
prdy <subcommand> PRD creation and validation
init Create docs/PRD.md via guided runtime session
update Update existing PRD via guided runtime session
validate Check PRD completeness (bash-only)
status Quick PRD health check (one-liner)
Coordinator (r0):
coord <subcommand> Manual coordinator tools
init Initialize a new mission
mission Show mission progress dashboard
status Check agent session health
continue Generate continuation prompt
run Generate context and launch selected runtime
resume Crash recovery
Options:
-h, --help Show this help
-v, --version Show version
All arguments after the command are forwarded to the target CLI.
USAGE
}
# Pre-flight checks
check_mosaic_home() {
if [[ ! -d "$MOSAIC_HOME" ]]; then
echo "[mosaic] ERROR: ~/.config/mosaic not found." >&2
echo "[mosaic] Install with: curl -sL https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.sh | sh" >&2
exit 1
fi
}
check_agents_md() {
if [[ ! -f "$MOSAIC_HOME/AGENTS.md" ]]; then
echo "[mosaic] ERROR: ~/.config/mosaic/AGENTS.md not found." >&2
echo "[mosaic] Re-run the installer: cd ~/src/mosaic-bootstrap && bash install.sh" >&2
exit 1
fi
}
check_soul() {
if [[ ! -f "$MOSAIC_HOME/SOUL.md" ]]; then
echo "[mosaic] SOUL.md not found. Running mosaic init..."
"$MOSAIC_HOME/bin/mosaic-init"
fi
}
check_runtime() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "[mosaic] ERROR: '$cmd' not found in PATH." >&2
echo "[mosaic] Install $cmd before launching." >&2
exit 1
fi
}
check_sequential_thinking() {
local runtime="${1:-all}"
local checker="$MOSAIC_HOME/bin/mosaic-ensure-sequential-thinking"
if [[ ! -x "$checker" ]]; then
echo "[mosaic] ERROR: sequential-thinking checker missing: $checker" >&2
exit 1
fi
if ! "$checker" --check --runtime "$runtime" >/dev/null 2>&1; then
echo "[mosaic] ERROR: sequential-thinking MCP is required but not configured." >&2
echo "[mosaic] Fix config: $checker --runtime $runtime" >&2
echo "[mosaic] Or run: mosaic seq fix --runtime $runtime" >&2
echo "[mosaic] Manual server start: mosaic seq start" >&2
exit 1
fi
}
runtime_contract_path() {
local runtime="$1"
case "$runtime" in
claude) echo "$MOSAIC_HOME/runtime/claude/RUNTIME.md" ;;
codex) echo "$MOSAIC_HOME/runtime/codex/RUNTIME.md" ;;
opencode) echo "$MOSAIC_HOME/runtime/opencode/RUNTIME.md" ;;
pi) echo "$MOSAIC_HOME/runtime/pi/RUNTIME.md" ;;
*)
echo "[mosaic] ERROR: unsupported runtime '$runtime' for runtime contract." >&2
exit 1
;;
esac
}
build_runtime_prompt() {
local runtime="$1"
local runtime_file
runtime_file="$(runtime_contract_path "$runtime")"
if [[ ! -f "$runtime_file" ]]; then
echo "[mosaic] ERROR: runtime contract not found: $runtime_file" >&2
exit 1
fi
# Inject active mission context FIRST so the agent sees it immediately
local mission_file=".mosaic/orchestrator/mission.json"
if [[ -f "$mission_file" ]] && command -v jq &>/dev/null; then
local m_status
m_status="$(jq -r '.status // "inactive"' "$mission_file" 2>/dev/null)"
if [[ "$m_status" == "active" || "$m_status" == "paused" ]]; then
local m_name m_id m_count m_completed
m_name="$(jq -r '.name // "unnamed"' "$mission_file")"
m_id="$(jq -r '.mission_id // ""' "$mission_file")"
m_count="$(jq '.milestones | length' "$mission_file")"
m_completed="$(jq '[.milestones[] | select(.status == "completed")] | length' "$mission_file")"
cat <<MISSION_EOF
# ACTIVE MISSION — HARD GATE (Read Before Anything Else)
An active orchestration mission exists in this project. This is a BLOCKING requirement.
**Mission:** $m_name
**ID:** $m_id
**Status:** $m_status
**Milestones:** $m_completed / $m_count completed
## MANDATORY — Before ANY Response to the User
You MUST complete these steps before responding to any user message, including simple greetings:
1. Read \`~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md\` (mission lifecycle protocol)
2. Read \`docs/MISSION-MANIFEST.md\` for full mission scope, milestones, and success criteria
3. Read the latest scratchpad in \`docs/scratchpads/\` for session history, decisions, and corrections
4. Read \`docs/TASKS.md\` for current task state (what is done, what is next)
5. After reading all four, acknowledge the mission state to the user before proceeding
If the user gives a task, execute it within the mission context. If no task is given, present mission status and ask how to proceed.
MISSION_EOF
fi
fi
# Inject PRD status so the agent knows requirements state
local prd_file="docs/PRD.md"
if [[ -f "$prd_file" ]]; then
local prd_sections=0
local prd_assumptions=0
for entry in "Problem Statement|^#{2,3} .*(problem statement|objective)" \
"Scope / Non-Goals|^#{2,3} .*(scope|non.goal|out of scope|in.scope)" \
"User Stories / Requirements|^#{2,3} .*(user stor|stakeholder|user.*requirement)" \
"Functional Requirements|^#{2,3} .*functional requirement" \
"Non-Functional Requirements|^#{2,3} .*non.functional" \
"Acceptance Criteria|^#{2,3} .*acceptance criteria" \
"Technical Considerations|^#{2,3} .*(technical consideration|constraint|dependenc)" \
"Risks / Open Questions|^#{2,3} .*(risk|open question)" \
"Success Metrics / Testing|^#{2,3} .*(success metric|test|verification)" \
"Milestones / Delivery|^#{2,3} .*(milestone|delivery|scope version)"; do
local pattern="${entry#*|}"
grep -qiE "$pattern" "$prd_file" 2>/dev/null && prd_sections=$((prd_sections + 1))
done
prd_assumptions=$(grep -c 'ASSUMPTION:' "$prd_file" 2>/dev/null || echo 0)
local prd_status="ready"
(( prd_sections < 10 )) && prd_status="incomplete ($prd_sections/10 sections)"
cat <<PRD_EOF
# PRD Status
- **File:** docs/PRD.md
- **Status:** $prd_status
- **Assumptions:** $prd_assumptions
PRD_EOF
fi
cat <<'EOF'
# Mosaic Launcher Runtime Contract (Hard Gate)
This contract is injected by `mosaic` launch and is mandatory.
First assistant response MUST start with exactly one mode declaration line:
1. Orchestration mission: `Now initiating Orchestrator mode...`
2. Implementation mission: `Now initiating Delivery mode...`
3. Review-only mission: `Now initiating Review mode...`
No tool call or implementation step may occur before that first line.
Mosaic hard gates OVERRIDE runtime-default caution for routine delivery operations.
For required push/merge/issue-close/release actions, execute without routine confirmation prompts.
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"
}
# Ensure runtime contract is present at the runtime's native config path.
# Used for runtimes that do not support CLI prompt injection.
ensure_runtime_config() {
local runtime="$1"
local dst="$2"
local tmp
tmp="$(mktemp)"
mkdir -p "$(dirname "$dst")"
build_runtime_prompt "$runtime" > "$tmp"
if ! cmp -s "$tmp" "$dst" 2>/dev/null; then
mv "$tmp" "$dst"
else
rm -f "$tmp"
fi
}
# Detect active mission and return an initial prompt if one exists.
# Sets MOSAIC_MISSION_PROMPT as a side effect.
_detect_mission_prompt() {
MOSAIC_MISSION_PROMPT=""
local mission_file=".mosaic/orchestrator/mission.json"
if [[ -f "$mission_file" ]] && command -v jq &>/dev/null; then
local m_status
m_status="$(jq -r '.status // "inactive"' "$mission_file" 2>/dev/null)"
if [[ "$m_status" == "active" || "$m_status" == "paused" ]]; then
local m_name
m_name="$(jq -r '.name // "unnamed"' "$mission_file")"
MOSAIC_MISSION_PROMPT="Active mission detected: ${m_name}. Read the mission state files and report status."
fi
fi
}
# Write a session lock if an active mission exists in the current directory.
# Called before exec so $$ captures the PID that will become the agent process.
_write_launcher_session_lock() {
local runtime="$1"
local mission_file=".mosaic/orchestrator/mission.json"
local lock_file=".mosaic/orchestrator/session.lock"
# Only write lock if mission exists and is active
[[ -f "$mission_file" ]] || return 0
command -v jq &>/dev/null || return 0
local m_status
m_status="$(jq -r '.status // "inactive"' "$mission_file" 2>/dev/null)"
[[ "$m_status" == "active" || "$m_status" == "paused" ]] || return 0
local session_id
session_id="${runtime}-$(date +%Y%m%d-%H%M%S)-$$"
jq -n \
--arg sid "$session_id" \
--arg rt "$runtime" \
--arg pid "$$" \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg pp "$(pwd)" \
--arg mid "" \
'{
session_id: $sid,
runtime: $rt,
pid: ($pid | tonumber),
started_at: $ts,
project_path: $pp,
milestone_id: $mid
}' > "$lock_file"
}
# Clean up session lock on exit (covers normal exit + signals).
# Registered via trap after _write_launcher_session_lock succeeds.
_cleanup_session_lock() {
rm -f ".mosaic/orchestrator/session.lock" 2>/dev/null
}
# Launcher functions
launch_claude() {
check_mosaic_home
check_agents_md
check_soul
check_runtime "claude"
check_sequential_thinking "claude"
_check_resumable_session
# Claude supports --append-system-prompt for direct injection
local runtime_prompt
runtime_prompt="$(build_runtime_prompt "claude")"
# If active mission exists and no user prompt was given, inject initial prompt
_detect_mission_prompt
_write_launcher_session_lock "claude"
trap _cleanup_session_lock EXIT INT TERM
if [[ -n "$MOSAIC_MISSION_PROMPT" && $# -eq 0 ]]; then
echo "[mosaic] Launching Claude Code (active mission detected)..."
exec claude --append-system-prompt "$runtime_prompt" "$MOSAIC_MISSION_PROMPT"
else
echo "[mosaic] Launching Claude Code..."
exec claude --append-system-prompt "$runtime_prompt" "$@"
fi
}
launch_opencode() {
check_mosaic_home
check_agents_md
check_soul
check_runtime "opencode"
check_sequential_thinking "opencode"
_check_resumable_session
# OpenCode reads from ~/.config/opencode/AGENTS.md
ensure_runtime_config "opencode" "$HOME/.config/opencode/AGENTS.md"
_write_launcher_session_lock "opencode"
trap _cleanup_session_lock EXIT INT TERM
echo "[mosaic] Launching OpenCode..."
exec opencode "$@"
}
launch_codex() {
check_mosaic_home
check_agents_md
check_soul
check_runtime "codex"
check_sequential_thinking "codex"
_check_resumable_session
# Codex reads from ~/.codex/instructions.md
ensure_runtime_config "codex" "$HOME/.codex/instructions.md"
_detect_mission_prompt
_write_launcher_session_lock "codex"
trap _cleanup_session_lock EXIT INT TERM
if [[ -n "$MOSAIC_MISSION_PROMPT" && $# -eq 0 ]]; then
echo "[mosaic] Launching Codex (active mission detected)..."
exec codex "$MOSAIC_MISSION_PROMPT"
else
echo "[mosaic] Launching Codex..."
exec codex "$@"
fi
}
launch_pi() {
check_mosaic_home
check_agents_md
check_soul
check_runtime "pi"
# Pi has native thinking levels — no sequential-thinking gate required
_check_resumable_session
local runtime_prompt
runtime_prompt="$(build_runtime_prompt "pi")"
# Build skill args from Mosaic skills directories (canonical + local)
local -a skill_args=()
for skills_root in "$MOSAIC_HOME/skills" "$MOSAIC_HOME/skills-local"; do
[[ -d "$skills_root" ]] || continue
for skill_dir in "$skills_root"/*/; do
[[ -f "${skill_dir}SKILL.md" ]] && skill_args+=(--skill "$skill_dir")
done
done
# Load Mosaic extension if present
local -a ext_args=()
local mosaic_ext="$MOSAIC_HOME/runtime/pi/mosaic-extension.ts"
[[ -f "$mosaic_ext" ]] && ext_args=(--extension "$mosaic_ext")
_detect_mission_prompt
_write_launcher_session_lock "pi"
trap _cleanup_session_lock EXIT INT TERM
if [[ -n "$MOSAIC_MISSION_PROMPT" && $# -eq 0 ]]; then
echo "[mosaic] Launching Pi (active mission detected)..."
exec pi --append-system-prompt "$runtime_prompt" \
"${skill_args[@]}" "${ext_args[@]}" "$MOSAIC_MISSION_PROMPT"
else
echo "[mosaic] Launching Pi..."
exec pi --append-system-prompt "$runtime_prompt" \
"${skill_args[@]}" "${ext_args[@]}" "$@"
fi
}
launch_yolo() {
if [[ $# -eq 0 ]]; then
echo "[mosaic] ERROR: yolo requires a runtime (claude|codex|opencode|pi)." >&2
echo "[mosaic] Example: mosaic yolo claude" >&2
exit 1
fi
local runtime="$1"
shift
case "$runtime" in
claude)
check_mosaic_home
check_agents_md
check_soul
check_runtime "claude"
check_sequential_thinking "claude"
# Claude uses an explicit dangerous permissions flag.
local runtime_prompt
runtime_prompt="$(build_runtime_prompt "claude")"
_detect_mission_prompt
_write_launcher_session_lock "claude"
trap _cleanup_session_lock EXIT INT TERM
if [[ -n "$MOSAIC_MISSION_PROMPT" && $# -eq 0 ]]; then
echo "[mosaic] Launching Claude Code in YOLO mode (active mission detected)..."
exec claude --dangerously-skip-permissions --append-system-prompt "$runtime_prompt" "$MOSAIC_MISSION_PROMPT"
else
echo "[mosaic] Launching Claude Code in YOLO mode (dangerous permissions enabled)..."
exec claude --dangerously-skip-permissions --append-system-prompt "$runtime_prompt" "$@"
fi
;;
codex)
check_mosaic_home
check_agents_md
check_soul
check_runtime "codex"
check_sequential_thinking "codex"
# Codex reads instructions.md from ~/.codex and supports a direct dangerous flag.
ensure_runtime_config "codex" "$HOME/.codex/instructions.md"
_detect_mission_prompt
_write_launcher_session_lock "codex"
trap _cleanup_session_lock EXIT INT TERM
if [[ -n "$MOSAIC_MISSION_PROMPT" && $# -eq 0 ]]; then
echo "[mosaic] Launching Codex in YOLO mode (active mission detected)..."
exec codex --dangerously-bypass-approvals-and-sandbox "$MOSAIC_MISSION_PROMPT"
else
echo "[mosaic] Launching Codex in YOLO mode (dangerous permissions enabled)..."
exec codex --dangerously-bypass-approvals-and-sandbox "$@"
fi
;;
opencode)
check_mosaic_home
check_agents_md
check_soul
check_runtime "opencode"
check_sequential_thinking "opencode"
# OpenCode defaults to allow-all permissions unless user config restricts them.
ensure_runtime_config "opencode" "$HOME/.config/opencode/AGENTS.md"
_write_launcher_session_lock "opencode"
trap _cleanup_session_lock EXIT INT TERM
echo "[mosaic] Launching OpenCode in YOLO mode..."
exec opencode "$@"
;;
pi)
# Pi has no permission restrictions — yolo is identical to normal launch
launch_pi "$@"
;;
*)
echo "[mosaic] ERROR: Unsupported yolo runtime '$runtime'. Use claude|codex|opencode|pi." >&2
exit 1
;;
esac
}
# 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" "$@"
}
run_doctor() {
check_mosaic_home
exec "$MOSAIC_HOME/bin/mosaic-doctor" "$@"
}
run_sync() {
check_mosaic_home
exec "$MOSAIC_HOME/bin/mosaic-sync-skills" "$@"
}
run_seq() {
check_mosaic_home
local checker="$MOSAIC_HOME/bin/mosaic-ensure-sequential-thinking"
local action="${1:-check}"
case "$action" in
check)
shift || true
exec "$checker" --check "$@"
;;
fix|apply)
shift || true
exec "$checker" "$@"
;;
start)
shift || true
check_runtime "npx"
echo "[mosaic] Starting sequential-thinking MCP server..."
exec npx -y @modelcontextprotocol/server-sequential-thinking "$@"
;;
*)
echo "[mosaic] ERROR: Unknown seq subcommand '$action'." >&2
echo "[mosaic] Use: mosaic seq check|fix|start" >&2
exit 1
;;
esac
}
run_coord() {
check_mosaic_home
local runtime="claude"
local runtime_flag=""
local yolo_flag=""
local -a coord_args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--claude|--codex|--pi)
local selected_runtime="${1#--}"
if [[ -n "$runtime_flag" ]] && [[ "$runtime" != "$selected_runtime" ]]; then
echo "[mosaic] ERROR: --claude, --codex, and --pi are mutually exclusive for 'mosaic coord'." >&2
exit 1
fi
runtime="$selected_runtime"
runtime_flag="$1"
shift
;;
--yolo)
yolo_flag="--yolo"
shift
;;
*)
coord_args+=("$1")
shift
;;
esac
done
local subcmd="${coord_args[0]:-help}"
if (( ${#coord_args[@]} > 1 )); then
set -- "${coord_args[@]:1}"
else
set --
fi
local tool_dir="$MOSAIC_HOME/tools/orchestrator"
case "$subcmd" in
status|session)
MOSAIC_COORD_RUNTIME="$runtime" exec bash "$tool_dir/session-status.sh" "$@"
;;
init)
MOSAIC_COORD_RUNTIME="$runtime" exec bash "$tool_dir/mission-init.sh" "$@"
;;
mission|progress)
MOSAIC_COORD_RUNTIME="$runtime" exec bash "$tool_dir/mission-status.sh" "$@"
;;
continue|next)
MOSAIC_COORD_RUNTIME="$runtime" exec bash "$tool_dir/continue-prompt.sh" "$@"
;;
run|start)
MOSAIC_COORD_RUNTIME="$runtime" exec bash "$tool_dir/session-run.sh" ${yolo_flag:+"$yolo_flag"} "$@"
;;
smoke|test)
MOSAIC_COORD_RUNTIME="$runtime" exec bash "$tool_dir/smoke-test.sh" "$@"
;;
resume|recover)
MOSAIC_COORD_RUNTIME="$runtime" exec bash "$tool_dir/session-resume.sh" "$@"
;;
help|*)
cat <<COORD_USAGE
mosaic coord — r0 manual coordinator tools
Commands:
init --name <name> [opts] Initialize a new mission
mission [--project <path>] Show mission progress dashboard
status [--project <path>] Check agent session health
continue [--project <path>] Generate continuation prompt for next session
run [--project <path>] Generate context and launch selected runtime
smoke Run orchestration behavior smoke checks
resume [--project <path>] Crash recovery (detect dirty state, generate fix)
Runtime:
--claude Use Claude runtime hints/prompts (default)
--codex Use Codex runtime hints/prompts
--pi Use Pi runtime hints/prompts
--yolo Launch runtime in dangerous/skip-permissions mode (run only)
Examples:
mosaic coord init --name "Security Fix" --milestones "Critical,High,Medium"
mosaic coord mission
mosaic coord --codex mission
mosaic coord --pi run
mosaic coord continue --copy
mosaic coord run
mosaic coord run --codex
mosaic coord --yolo run
mosaic coord smoke
mosaic coord continue --codex --copy
COORD_USAGE
;;
esac
}
# Resume advisory — prints warning if active mission or stale session detected
_check_resumable_session() {
local mission_file=".mosaic/orchestrator/mission.json"
local lock_file=".mosaic/orchestrator/session.lock"
command -v jq &>/dev/null || return 0
if [[ -f "$lock_file" ]]; then
local pid
pid="$(jq -r '.pid // 0' "$lock_file" 2>/dev/null)"
if [[ -n "$pid" ]] && [[ "$pid" != "0" ]] && ! kill -0 "$pid" 2>/dev/null; then
# Stale lock from a dead session — clean it up
rm -f "$lock_file"
echo "[mosaic] Cleaned up stale session lock (PID $pid no longer running)."
echo ""
fi
elif [[ -f "$mission_file" ]]; then
local status
status="$(jq -r '.status // "inactive"' "$mission_file" 2>/dev/null)"
if [[ "$status" == "active" ]]; then
echo "[mosaic] Active mission detected. Generate continuation prompt with:"
echo "[mosaic] mosaic coord continue"
echo ""
fi
fi
}
run_prdy() {
check_mosaic_home
local runtime="claude"
local runtime_flag=""
local -a prdy_args=()
while [[ $# -gt 0 ]]; do
case "$1" in
--claude|--codex|--pi)
local selected_runtime="${1#--}"
if [[ -n "$runtime_flag" ]] && [[ "$runtime" != "$selected_runtime" ]]; then
echo "[mosaic] ERROR: --claude, --codex, and --pi are mutually exclusive for 'mosaic prdy'." >&2
exit 1
fi
runtime="$selected_runtime"
runtime_flag="$1"
shift
;;
*)
prdy_args+=("$1")
shift
;;
esac
done
local subcmd="${prdy_args[0]:-help}"
if (( ${#prdy_args[@]} > 1 )); then
set -- "${prdy_args[@]:1}"
else
set --
fi
local tool_dir="$MOSAIC_HOME/tools/prdy"
case "$subcmd" in
init)
MOSAIC_PRDY_RUNTIME="$runtime" exec bash "$tool_dir/prdy-init.sh" "$@"
;;
update)
MOSAIC_PRDY_RUNTIME="$runtime" exec bash "$tool_dir/prdy-update.sh" "$@"
;;
validate|check)
MOSAIC_PRDY_RUNTIME="$runtime" exec bash "$tool_dir/prdy-validate.sh" "$@"
;;
status)
exec bash "$tool_dir/prdy-status.sh" "$@"
;;
help|*)
cat <<PRDY_USAGE
mosaic prdy — PRD creation and validation tools
Commands:
init [--project <path>] [--name <feature>] Create docs/PRD.md via guided runtime session
update [--project <path>] Update existing docs/PRD.md via guided runtime session
validate [--project <path>] Check PRD completeness against Mosaic guide (bash-only)
status [--project <path>] [--format short|json] Quick PRD health check (one-liner)
Runtime:
--claude Use Claude runtime (default)
--codex Use Codex runtime
--pi Use Pi runtime
Examples:
mosaic prdy init --name "User Authentication"
mosaic prdy update
mosaic prdy --pi init --name "User Authentication"
mosaic prdy --codex init --name "User Authentication"
mosaic prdy validate
Output location: docs/PRD.md (per Mosaic PRD guide)
PRDY_USAGE
;;
esac
}
run_bootstrap() {
check_mosaic_home
exec "$MOSAIC_HOME/bin/mosaic-bootstrap-repo" "$@"
}
run_release_upgrade() {
check_mosaic_home
exec "$MOSAIC_HOME/bin/mosaic-release-upgrade" "$@"
}
run_project_upgrade() {
check_mosaic_home
exec "$MOSAIC_HOME/bin/mosaic-upgrade" "$@"
}
run_upgrade() {
check_mosaic_home
# Default: upgrade installed release
if [[ $# -eq 0 ]]; then
run_release_upgrade
fi
case "$1" in
release)
shift
run_release_upgrade "$@"
;;
check)
shift
run_release_upgrade --dry-run "$@"
;;
project)
shift
run_project_upgrade "$@"
;;
# Backward compatibility for historical project-upgrade usage.
--all|--root)
run_project_upgrade "$@"
;;
--dry-run|--ref|--keep|--overwrite|-y|--yes)
run_release_upgrade "$@"
;;
-*)
run_release_upgrade "$@"
;;
*)
run_project_upgrade "$@"
;;
esac
}
# Main router
if [[ $# -eq 0 ]]; then
usage
exit 0
fi
command="$1"
shift
case "$command" in
pi) launch_pi "$@" ;;
claude) launch_claude "$@" ;;
opencode) launch_opencode "$@" ;;
codex) launch_codex "$@" ;;
yolo|--yolo) launch_yolo "$@" ;;
init) run_init "$@" ;;
doctor) run_doctor "$@" ;;
sync) run_sync "$@" ;;
seq) run_seq "$@" ;;
bootstrap) run_bootstrap "$@" ;;
prdy) run_prdy "$@" ;;
coord) run_coord "$@" ;;
upgrade) run_upgrade "$@" ;;
release-upgrade) run_release_upgrade "$@" ;;
project-upgrade) run_project_upgrade "$@" ;;
help|-h|--help) usage ;;
version|-v|--version) echo "mosaic $VERSION" ;;
*)
echo "[mosaic] Unknown command: $command" >&2
echo "[mosaic] Run 'mosaic --help' for usage." >&2
exit 1
;;
esac

View File

@@ -1,437 +0,0 @@
# mosaic.ps1 — Unified agent launcher and management CLI (Windows)
#
# AGENTS.md is the global policy source for all agent sessions.
# The launcher injects a composed runtime contract (AGENTS + runtime reference).
#
# Usage:
# mosaic claude [args...] Launch Claude Code with runtime contract injected
# mosaic opencode [args...] Launch OpenCode with runtime contract injected
# mosaic codex [args...] Launch Codex with runtime contract injected
# mosaic yolo <runtime> [args...] Launch runtime in dangerous-permissions mode
# mosaic --yolo <runtime> [args...] Alias for yolo
# mosaic init [args...] Generate SOUL.md interactively
# mosaic doctor [args...] Health audit
# mosaic sync [args...] Sync skills
$ErrorActionPreference = "Stop"
$MosaicHome = if ($env:MOSAIC_HOME) { $env:MOSAIC_HOME } else { Join-Path $env:USERPROFILE ".config\mosaic" }
$Version = "0.1.0"
function Show-Usage {
Write-Host @"
mosaic $Version - Unified agent launcher
Usage: mosaic <command> [args...]
Agent Launchers:
claude [args...] Launch Claude Code with runtime contract injected
opencode [args...] Launch OpenCode with runtime contract injected
codex [args...] Launch Codex with runtime contract injected
yolo <runtime> [args...] Dangerous mode for claude|codex|opencode
--yolo <runtime> [args...] Alias for yolo
Management:
init [args...] Generate SOUL.md (agent identity contract)
doctor [args...] Audit runtime state and detect drift
sync [args...] Sync skills from canonical source
bootstrap <path> Bootstrap a repo with Mosaic standards
upgrade [mode] [args] Upgrade release (default) or project files
upgrade check Check release upgrade status (no changes)
release-upgrade [...] Upgrade installed Mosaic release
project-upgrade [...] Clean up stale SOUL.md/CLAUDE.md in a project
Options:
-h, --help Show this help
-v, --version Show version
"@
}
function Assert-MosaicHome {
if (-not (Test-Path $MosaicHome)) {
Write-Host "[mosaic] ERROR: ~/.config/mosaic not found." -ForegroundColor Red
Write-Host "[mosaic] Install with: irm https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.ps1 | iex"
exit 1
}
}
function Assert-AgentsMd {
$agentsPath = Join-Path $MosaicHome "AGENTS.md"
if (-not (Test-Path $agentsPath)) {
Write-Host "[mosaic] ERROR: ~/.config/mosaic/AGENTS.md not found." -ForegroundColor Red
Write-Host "[mosaic] Re-run the installer."
exit 1
}
}
function Assert-Soul {
$soulPath = Join-Path $MosaicHome "SOUL.md"
if (-not (Test-Path $soulPath)) {
Write-Host "[mosaic] SOUL.md not found. Running mosaic init..."
& (Join-Path $MosaicHome "bin\mosaic-init.ps1")
}
}
function Assert-Runtime {
param([string]$Cmd)
if (-not (Get-Command $Cmd -ErrorAction SilentlyContinue)) {
Write-Host "[mosaic] ERROR: '$Cmd' not found in PATH." -ForegroundColor Red
Write-Host "[mosaic] Install $Cmd before launching."
exit 1
}
}
function Assert-SequentialThinking {
$checker = Join-Path $MosaicHome "bin\mosaic-ensure-sequential-thinking.ps1"
if (-not (Test-Path $checker)) {
Write-Host "[mosaic] ERROR: sequential-thinking checker missing: $checker" -ForegroundColor Red
exit 1
}
try {
& $checker -Check *>$null
}
catch {
Write-Host "[mosaic] ERROR: sequential-thinking MCP is required but not configured." -ForegroundColor Red
Write-Host "[mosaic] Run: $checker"
exit 1
}
}
function Get-ActiveMission {
$missionFile = Join-Path (Get-Location) ".mosaic\orchestrator\mission.json"
if (-not (Test-Path $missionFile)) {
return $null
}
try {
$mission = Get-Content $missionFile -Raw | ConvertFrom-Json
}
catch {
return $null
}
$status = [string]$mission.status
if ([string]::IsNullOrWhiteSpace($status)) {
$status = "inactive"
}
if ($status -ne "active" -and $status -ne "paused") {
return $null
}
$name = [string]$mission.name
if ([string]::IsNullOrWhiteSpace($name)) {
$name = "unnamed"
}
$id = [string]$mission.mission_id
if ([string]::IsNullOrWhiteSpace($id)) {
$id = ""
}
$milestones = @($mission.milestones)
$milestoneCount = $milestones.Count
$milestoneCompleted = @($milestones | Where-Object { $_.status -eq "completed" }).Count
return [PSCustomObject]@{
Name = $name
Id = $id
Status = $status
MilestoneCount = $milestoneCount
MilestoneCompleted = $milestoneCompleted
}
}
function Get-MissionContractBlock {
$mission = Get-ActiveMission
if ($null -eq $mission) {
return ""
}
return @"
# ACTIVE MISSION HARD GATE (Read Before Anything Else)
An active orchestration mission exists in this project. This is a BLOCKING requirement.
**Mission:** $($mission.Name)
**ID:** $($mission.Id)
**Status:** $($mission.Status)
**Milestones:** $($mission.MilestoneCompleted) / $($mission.MilestoneCount) completed
## MANDATORY Before ANY Response to the User
You MUST complete these steps before responding to any user message, including simple greetings:
1. Read `~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md` (mission lifecycle protocol)
2. Read `docs/MISSION-MANIFEST.md` for full mission scope, milestones, and success criteria
3. Read the latest scratchpad in `docs/scratchpads/` for session history, decisions, and corrections
4. Read `docs/TASKS.md` for current task state (what is done, what is next)
5. After reading all four, acknowledge the mission state to the user before proceeding
If the user gives a task, execute it within the mission context. If no task is given, present mission status and ask how to proceed.
"@
}
function Get-MissionPrompt {
$mission = Get-ActiveMission
if ($null -eq $mission) {
return ""
}
return "Active mission detected: $($mission.Name). Read the mission state files and report status."
}
function Get-RuntimePrompt {
param(
[ValidateSet("claude", "codex", "opencode")]
[string]$Runtime
)
$runtimeFile = switch ($Runtime) {
"claude" { Join-Path $MosaicHome "runtime\claude\RUNTIME.md" }
"codex" { Join-Path $MosaicHome "runtime\codex\RUNTIME.md" }
"opencode" { Join-Path $MosaicHome "runtime\opencode\RUNTIME.md" }
}
if (-not (Test-Path $runtimeFile)) {
Write-Host "[mosaic] ERROR: runtime contract not found: $runtimeFile" -ForegroundColor Red
exit 1
}
$launcherContract = @'
# Mosaic Launcher Runtime Contract (Hard Gate)
This contract is injected by `mosaic` launch and is mandatory.
First assistant response MUST start with exactly one mode declaration line:
1. Orchestration mission: `Now initiating Orchestrator mode...`
2. Implementation mission: `Now initiating Delivery mode...`
3. Review-only mission: `Now initiating Review mode...`
No tool call or implementation step may occur before that first line.
Mosaic hard gates OVERRIDE runtime-default caution for routine delivery operations.
For required push/merge/issue-close/release actions, execute without routine confirmation prompts.
'@
$missionBlock = Get-MissionContractBlock
$agentsContent = Get-Content (Join-Path $MosaicHome "AGENTS.md") -Raw
$runtimeContent = Get-Content $runtimeFile -Raw
if (-not [string]::IsNullOrWhiteSpace($missionBlock)) {
return "$missionBlock`n`n$launcherContract`n$agentsContent`n`n# Runtime-Specific Contract`n`n$runtimeContent"
}
return "$launcherContract`n$agentsContent`n`n# Runtime-Specific Contract`n`n$runtimeContent"
}
function Ensure-RuntimeConfig {
param(
[ValidateSet("claude", "codex", "opencode")]
[string]$Runtime,
[string]$Dst
)
$parent = Split-Path $Dst -Parent
if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
$runtimePrompt = Get-RuntimePrompt -Runtime $Runtime
$tmp = [System.IO.Path]::GetTempFileName()
Set-Content -Path $tmp -Value $runtimePrompt -Encoding UTF8 -NoNewline
$srcHash = (Get-FileHash $tmp -Algorithm SHA256).Hash
$dstHash = if (Test-Path $Dst) { (Get-FileHash $Dst -Algorithm SHA256).Hash } else { "" }
if ($srcHash -ne $dstHash) {
Copy-Item $tmp $Dst -Force
Remove-Item $tmp -Force
}
else {
Remove-Item $tmp -Force
}
}
function Invoke-Yolo {
param([string[]]$YoloArgs)
if ($YoloArgs.Count -lt 1) {
Write-Host "[mosaic] ERROR: yolo requires a runtime (claude|codex|opencode)." -ForegroundColor Red
Write-Host "[mosaic] Example: mosaic yolo claude"
exit 1
}
$runtime = $YoloArgs[0]
$tail = if ($YoloArgs.Count -gt 1) { @($YoloArgs[1..($YoloArgs.Count - 1)]) } else { @() }
switch ($runtime) {
"claude" {
Assert-MosaicHome
Assert-AgentsMd
Assert-Soul
Assert-Runtime "claude"
Assert-SequentialThinking
$agentsContent = Get-RuntimePrompt -Runtime "claude"
Write-Host "[mosaic] Launching Claude Code in YOLO mode (dangerous permissions enabled)..."
& claude --dangerously-skip-permissions --append-system-prompt $agentsContent @tail
return
}
"codex" {
Assert-MosaicHome
Assert-AgentsMd
Assert-Soul
Assert-Runtime "codex"
Assert-SequentialThinking
Ensure-RuntimeConfig -Runtime "codex" -Dst (Join-Path $env:USERPROFILE ".codex\instructions.md")
$missionPrompt = Get-MissionPrompt
if (-not [string]::IsNullOrWhiteSpace($missionPrompt) -and $tail.Count -eq 0) {
Write-Host "[mosaic] Launching Codex in YOLO mode (active mission detected)..."
& codex --dangerously-bypass-approvals-and-sandbox $missionPrompt
}
else {
Write-Host "[mosaic] Launching Codex in YOLO mode (dangerous permissions enabled)..."
& codex --dangerously-bypass-approvals-and-sandbox @tail
}
return
}
"opencode" {
Assert-MosaicHome
Assert-AgentsMd
Assert-Soul
Assert-Runtime "opencode"
Assert-SequentialThinking
Ensure-RuntimeConfig -Runtime "opencode" -Dst (Join-Path $env:USERPROFILE ".config\opencode\AGENTS.md")
Write-Host "[mosaic] Launching OpenCode in YOLO mode..."
& opencode @tail
return
}
default {
Write-Host "[mosaic] ERROR: Unsupported yolo runtime '$runtime'. Use claude|codex|opencode." -ForegroundColor Red
exit 1
}
}
}
if ($args.Count -eq 0) {
Show-Usage
exit 0
}
$command = $args[0]
$remaining = if ($args.Count -gt 1) { @($args[1..($args.Count - 1)]) } else { @() }
switch ($command) {
"claude" {
Assert-MosaicHome
Assert-AgentsMd
Assert-Soul
Assert-Runtime "claude"
Assert-SequentialThinking
# Claude supports --append-system-prompt for direct injection
$agentsContent = Get-RuntimePrompt -Runtime "claude"
Write-Host "[mosaic] Launching Claude Code..."
& claude --append-system-prompt $agentsContent @remaining
}
"opencode" {
Assert-MosaicHome
Assert-AgentsMd
Assert-Soul
Assert-Runtime "opencode"
Assert-SequentialThinking
# OpenCode reads from ~/.config/opencode/AGENTS.md
Ensure-RuntimeConfig -Runtime "opencode" -Dst (Join-Path $env:USERPROFILE ".config\opencode\AGENTS.md")
Write-Host "[mosaic] Launching OpenCode..."
& opencode @remaining
}
"codex" {
Assert-MosaicHome
Assert-AgentsMd
Assert-Soul
Assert-Runtime "codex"
Assert-SequentialThinking
# Codex reads from ~/.codex/instructions.md
Ensure-RuntimeConfig -Runtime "codex" -Dst (Join-Path $env:USERPROFILE ".codex\instructions.md")
$missionPrompt = Get-MissionPrompt
if (-not [string]::IsNullOrWhiteSpace($missionPrompt) -and $remaining.Count -eq 0) {
Write-Host "[mosaic] Launching Codex (active mission detected)..."
& codex $missionPrompt
}
else {
Write-Host "[mosaic] Launching Codex..."
& codex @remaining
}
}
"yolo" {
Invoke-Yolo -YoloArgs $remaining
}
"--yolo" {
Invoke-Yolo -YoloArgs $remaining
}
"init" {
Assert-MosaicHome
& (Join-Path $MosaicHome "bin\mosaic-init.ps1") @remaining
}
"doctor" {
Assert-MosaicHome
& (Join-Path $MosaicHome "bin\mosaic-doctor.ps1") @remaining
}
"sync" {
Assert-MosaicHome
& (Join-Path $MosaicHome "bin\mosaic-sync-skills.ps1") @remaining
}
"bootstrap" {
Assert-MosaicHome
Write-Host "[mosaic] NOTE: mosaic-bootstrap-repo requires bash. Use Git Bash or WSL." -ForegroundColor Yellow
& (Join-Path $MosaicHome "bin\mosaic-bootstrap-repo") @remaining
}
"upgrade" {
Assert-MosaicHome
if ($remaining.Count -eq 0) {
& (Join-Path $MosaicHome "bin\mosaic-release-upgrade.ps1")
break
}
$mode = $remaining[0]
$tail = if ($remaining.Count -gt 1) { $remaining[1..($remaining.Count - 1)] } else { @() }
switch -Regex ($mode) {
"^release$" {
& (Join-Path $MosaicHome "bin\mosaic-release-upgrade.ps1") @tail
}
"^check$" {
& (Join-Path $MosaicHome "bin\mosaic-release-upgrade.ps1") -DryRun @tail
}
"^project$" {
Write-Host "[mosaic] NOTE: mosaic-upgrade requires bash. Use Git Bash or WSL." -ForegroundColor Yellow
& (Join-Path $MosaicHome "bin\mosaic-upgrade") @tail
}
"^(--all|--root)$" {
Write-Host "[mosaic] NOTE: mosaic-upgrade requires bash. Use Git Bash or WSL." -ForegroundColor Yellow
& (Join-Path $MosaicHome "bin\mosaic-upgrade") @remaining
}
"^(--dry-run|--ref|--keep|--overwrite|-y|--yes)$" {
& (Join-Path $MosaicHome "bin\mosaic-release-upgrade.ps1") @remaining
}
"^-.*" {
& (Join-Path $MosaicHome "bin\mosaic-release-upgrade.ps1") @remaining
}
default {
Write-Host "[mosaic] NOTE: treating positional argument as project path." -ForegroundColor Yellow
Write-Host "[mosaic] NOTE: mosaic-upgrade requires bash. Use Git Bash or WSL." -ForegroundColor Yellow
& (Join-Path $MosaicHome "bin\mosaic-upgrade") @remaining
}
}
}
"release-upgrade" {
Assert-MosaicHome
& (Join-Path $MosaicHome "bin\mosaic-release-upgrade.ps1") @remaining
}
"project-upgrade" {
Assert-MosaicHome
Write-Host "[mosaic] NOTE: mosaic-upgrade requires bash. Use Git Bash or WSL." -ForegroundColor Yellow
& (Join-Path $MosaicHome "bin\mosaic-upgrade") @remaining
}
{ $_ -in "help", "-h", "--help" } { Show-Usage }
{ $_ -in "version", "-v", "--version" } { Write-Host "mosaic $Version" }
default {
Write-Host "[mosaic] Unknown command: $command" -ForegroundColor Red
Write-Host "[mosaic] Run 'mosaic --help' for usage."
exit 1
}
}

View File

@@ -1,43 +1,41 @@
# Mosaic Agent Framework # Mosaic Agent Framework
Universal agent standards layer for Claude Code, Codex, and OpenCode. Universal agent standards layer for Claude Code, Codex, OpenCode, and Pi.
One config, every runtime, same standards. 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. > **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.
## Quick Install ## Quick Install
### Mac / Linux ### Mac / Linux
```bash ```bash
curl -sL https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.sh | sh bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
``` ```
### Windows (PowerShell) ### Windows (PowerShell)
```powershell ```powershell
irm https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.ps1 | iex # PowerShell installer coming soon — use WSL + the bash installer above.
``` ```
### From Source (any platform) ### From Source (any platform)
```bash ```bash
git clone https://git.mosaicstack.dev/mosaic/bootstrap.git ~/src/mosaic-bootstrap git clone git@git.mosaicstack.dev:mosaic/mosaic-stack.git ~/src/mosaic-stack
cd ~/src/mosaic-bootstrap && bash install.sh cd ~/src/mosaic-stack && bash tools/install.sh
``` ```
If Node.js 18+ is available, the remote installer automatically uses the TypeScript wizard instead of the bash installer for a richer setup experience. The installer:
The installer will: - Downloads the framework from the monorepo archive
- Installs it to `~/.config/mosaic/`
- Install the framework to `~/.config/mosaic/` - Installs `@mosaic/cli` globally via npm (TUI, gateway client, wizard)
- Add `~/.config/mosaic/bin` to your PATH - Adds `~/.config/mosaic/bin` to your PATH
- Sync runtime adapters and skills - Syncs runtime adapters and skills
- Install and configure sequential-thinking MCP (hard requirement) - Runs a health audit
- Run a health audit - Detects existing installs and preserves local files (SOUL.md, USER.md, etc.)
- Detect existing installs and prompt to keep or overwrite local files
- Prompt you to run `mosaic init` to set up your agent identity
## First Run ## First Run
@@ -58,7 +56,7 @@ The wizard configures three files loaded into every agent session:
- `USER.md` — your user profile (name, timezone, accessibility, preferences) - `USER.md` — your user profile (name, timezone, accessibility, preferences)
- `TOOLS.md` — machine-level tool reference (git providers, credentials, CLI patterns) - `TOOLS.md` — machine-level tool reference (git providers, credentials, CLI patterns)
It also detects installed runtimes (Claude, Codex, OpenCode), configures sequential-thinking MCP, and offers curated skill selection from 8 categories. It also detects installed runtimes (Claude, Codex, OpenCode, Pi), configures sequential-thinking MCP, and offers curated skill selection from 8 categories.
### Non-Interactive Mode ### Non-Interactive Mode
@@ -77,9 +75,12 @@ If Node.js is unavailable, `mosaic init` falls back to the bash-based `mosaic-in
## Launching Agent Sessions ## Launching Agent Sessions
```bash ```bash
mosaic pi # Launch Pi with full Mosaic injection (recommended)
mosaic claude # Launch Claude Code with full Mosaic injection mosaic claude # Launch Claude Code with full Mosaic injection
mosaic codex # Launch Codex with full Mosaic injection mosaic codex # Launch Codex with full Mosaic injection
mosaic opencode # Launch OpenCode with full Mosaic injection mosaic opencode # Launch OpenCode with full Mosaic injection
mosaic yolo claude # Launch Claude in dangerous-permissions mode
mosaic yolo pi # Launch Pi in yolo mode
``` ```
The launcher: The launcher:
@@ -100,23 +101,18 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
├── USER.md ← User profile and accessibility (generated by mosaic init) ├── USER.md ← User profile and accessibility (generated by mosaic init)
├── TOOLS.md ← Machine-level tool reference (generated by mosaic init) ├── TOOLS.md ← Machine-level tool reference (generated by mosaic init)
├── STANDARDS.md ← Machine-wide standards ├── STANDARDS.md ← Machine-wide standards
├── guides/E2E-DELIVERY.md ← Mandatory E2E software delivery procedure ├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.)
├── guides/PRD.md ← Mandatory PRD requirements gate before coding ├── bin/ ← CLI tools (mosaic launcher, mosaic-init, mosaic-doctor, etc.)
├── guides/DOCUMENTATION.md ← Mandatory documentation standard and gates ├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc.
├── bin/ ← CLI tools (mosaic, mosaic-init, mosaic-doctor, etc.)
├── dist/ ← Bundled wizard (mosaic-wizard.mjs)
├── guides/ ← Operational guides
├── tools/ ← Tool suites: git, portainer, authentik, coolify, codex, etc.
├── runtime/ ← Runtime adapters + runtime-specific references ├── runtime/ ← Runtime adapters + runtime-specific references
│ ├── claude/CLAUDE.md │ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
│ ├── claude/RUNTIME.md │ ├── codex/ ← instructions.md, RUNTIME.md
│ ├── opencode/AGENTS.md │ ├── opencode/ ← AGENTS.md, RUNTIME.md
│ ├── opencode/RUNTIME.md │ ├── pi/ ← RUNTIME.md, mosaic-extension.ts
── codex/instructions.md ── mcp/ ← MCP server configs
│ ├── codex/RUNTIME.md
│ └── mcp/SEQUENTIAL-THINKING.json
├── skills/ ← Universal skills (synced from mosaic/agent-skills) ├── skills/ ← Universal skills (synced from mosaic/agent-skills)
├── skills-local/ ← Local cross-runtime skills ├── skills-local/ ← Local cross-runtime skills
├── memory/ ← Persistent agent memory (preserved across upgrades)
└── templates/ ← SOUL.md template, project templates └── templates/ ← SOUL.md template, project templates
``` ```
@@ -124,6 +120,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
| Launch method | Injection mechanism | | Launch method | Injection mechanism |
| ------------------- | ----------------------------------------------------------------------------------------- | | ------------------- | ----------------------------------------------------------------------------------------- |
| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + extension |
| `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) | | `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) |
| `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch | | `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch |
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch | | `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
@@ -131,9 +128,6 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
| `codex` (direct) | `~/.codex/instructions.md` thin pointer → load AGENTS + runtime reference | | `codex` (direct) | `~/.codex/instructions.md` thin pointer → load AGENTS + runtime reference |
| `opencode` (direct) | `~/.config/opencode/AGENTS.md` thin pointer → load AGENTS + runtime reference | | `opencode` (direct) | `~/.config/opencode/AGENTS.md` thin pointer → load AGENTS + runtime reference |
Mosaic `AGENTS.md` enforces loading `guides/E2E-DELIVERY.md` before execution and
requires `guides/PRD.md` before coding and `guides/DOCUMENTATION.md` for code/API/auth/infra documentation gates.
## Management Commands ## Management Commands
```bash ```bash
@@ -142,126 +136,53 @@ mosaic init # Interactive wizard (or legacy init)
mosaic doctor # Health audit — detect drift and missing files mosaic doctor # Health audit — detect drift and missing files
mosaic sync # Sync skills from canonical source mosaic sync # Sync skills from canonical source
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
mosaic upgrade check # Check release upgrade status (no changes) mosaic upgrade # Upgrade installed Mosaic release
mosaic upgrade # Upgrade installed Mosaic release (keeps SOUL.md by default) mosaic upgrade check # Check upgrade status (no changes)
mosaic upgrade --dry-run # Preview release upgrade without changes
mosaic upgrade --ref main # Upgrade from a specific branch/tag/commit ref
mosaic upgrade --overwrite # Upgrade release and overwrite local files
mosaic upgrade project ... # Project file cleanup mode (see below)
``` ```
## Upgrading Mosaic Release ## Upgrading
Upgrade the installed framework in place: Run the installer again — it handles upgrades automatically:
```bash ```bash
# Default (safe): keep local SOUL.md, USER.md, TOOLS.md + memory bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
mosaic upgrade
# Check current/target release info without changing files
mosaic upgrade check
# Non-interactive
mosaic upgrade --yes
# Pull a specific ref
mosaic upgrade --ref main
# Force full overwrite (fresh install semantics)
mosaic upgrade --overwrite --yes
``` ```
`mosaic upgrade` re-runs the remote installer and passes install mode controls (`keep`/`overwrite`). Or from a local checkout:
This is the manual upgrade path today and is suitable for future app-driven update checks.
## Upgrading Projects
After centralizing AGENTS.md and SOUL.md, existing projects may have stale files:
```bash ```bash
# Preview what would change across all projects cd ~/src/mosaic-stack && git pull && bash tools/install.sh
mosaic upgrade project --all --dry-run
# Apply to all projects
mosaic upgrade project --all
# Apply to a specific project
mosaic upgrade project ~/src/my-project
``` ```
Backward compatibility is preserved for historical usage: The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by default.
### Flags
```bash ```bash
mosaic upgrade --all # still routes to project-upgrade bash tools/install.sh --check # Version check only
mosaic upgrade ~/src/my-repo # still routes to project-upgrade bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --ref v1.0 # Install from a specific git ref
``` ```
What it does per project:
| File | Action |
| ----------- | ------------------------------------------------------------- |
| `SOUL.md` | Removed — now global at `~/.config/mosaic/SOUL.md` |
| `CLAUDE.md` | Replaced with thin pointer to global AGENTS.md |
| `AGENTS.md` | Stale load-order sections stripped; project content preserved |
Backups (`.mosaic-bak`) are created before any modification.
## Universal Skills ## Universal Skills
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories (`~/.claude/skills`, `~/.codex/skills`, `~/.config/opencode/skills`). The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories.
```bash ```bash
mosaic sync # Full sync (clone + link) mosaic sync # Full sync (clone + link)
~/.config/mosaic/bin/mosaic-sync-skills --link-only # Re-link only ~/.config/mosaic/bin/mosaic-sync-skills --link-only # Re-link only
``` ```
## Runtime Compatibility ## Health Audit
The installer pushes thin runtime adapters as regular files (not symlinks):
- `~/.claude/CLAUDE.md` — pointer to `~/.config/mosaic/AGENTS.md`
- `~/.claude/settings.json`, `hooks-config.json`, `context7-integration.md`
- `~/.config/opencode/AGENTS.md` — pointer to `~/.config/mosaic/AGENTS.md`
- `~/.codex/instructions.md` — pointer to `~/.config/mosaic/AGENTS.md`
- `~/.claude/settings.json`, `~/.codex/config.toml`, and `~/.config/opencode/config.json` include sequential-thinking MCP config
Re-sync manually:
```bash ```bash
~/.config/mosaic/bin/mosaic-link-runtime-assets mosaic doctor # Standard audit
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
``` ```
## MCP Registration ## MCP Registration
### How MCPs Are Configured in Claude Code
**MCPs must be registered via `claude mcp add` — not by hand-editing `~/.claude/settings.json`.**
`settings.json` controls hooks, model, plugins, and allowed commands. The `mcpServers` key in
`settings.json` is silently ignored by Claude Code's MCP loader. The correct file is `~/.claude.json`,
which is managed by the `claude mcp` CLI.
```bash
# Register a stdio MCP (user scope = all projects, persists across sessions)
claude mcp add --scope user <name> -- npx -y <package>
# Register an HTTP MCP (e.g. OpenBrain)
claude mcp add --scope user --transport http <name> <url> \
--header "Authorization: Bearer <token>"
# List registered MCPs
claude mcp list
```
**Scope options:**
- `--scope user` — writes to `~/.claude.json`, available in all projects (recommended for shared tools)
- `--scope project` — writes to `.claude/settings.json` in the project root, committed to the repo
- `--scope local` — default, machine-local only, not committed
**Transport for HTTP MCPs must be `http`** — not `sse`. `type: "sse"` is a deprecated protocol
that silently fails to connect against FastMCP streamable HTTP servers.
### sequential-thinking MCP (Hard Requirement) ### sequential-thinking MCP (Hard Requirement)
sequential-thinking MCP is required for Mosaic Stack. The installer registers it automatically. sequential-thinking MCP is required for Mosaic Stack. The installer registers it automatically.
@@ -272,74 +193,12 @@ To verify or re-register manually:
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check ~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
``` ```
### OpenBrain Semantic Memory (Recommended) ### Claude Code MCP Registration
OpenBrain is the shared cross-agent memory layer. Register once per machine: **MCPs must be registered via `claude mcp add` — not by hand-editing `~/.claude/settings.json`.**
```bash ```bash
claude mcp add --scope user --transport http openbrain https://your-openbrain-host/mcp \ claude mcp add --scope user <name> -- npx -y <package>
--header "Authorization: Bearer YOUR_TOKEN" claude mcp add --scope user --transport http <name> <url> --header "Authorization: Bearer <token>"
``` claude mcp list
See [mosaic/openbrain](https://git.mosaicstack.dev/mosaic/openbrain) for setup and API docs.
## Bootstrap Any Repo
Attach any repository to the Mosaic standards layer:
```bash
mosaic bootstrap /path/to/repo
```
This creates `.mosaic/`, `scripts/agent/`, and an `AGENTS.md` if missing.
## Quality Rails
Apply and verify quality templates:
```bash
~/.config/mosaic/bin/mosaic-quality-apply --template typescript-node --target /path/to/repo
~/.config/mosaic/bin/mosaic-quality-verify --target /path/to/repo
```
Templates: `typescript-node`, `typescript-nextjs`, `monorepo`
## Health Audit
```bash
mosaic doctor # Standard audit
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
```
## Wizard Development
The installation wizard is a TypeScript project in the root of this repo.
```bash
pnpm install # Install dependencies
pnpm dev # Run wizard from source (tsx)
pnpm build # Bundle to dist/mosaic-wizard.mjs
pnpm test # Run tests (30 tests, vitest)
pnpm typecheck # TypeScript type checking
```
The wizard uses `@clack/prompts` for the interactive TUI and supports `--non-interactive` mode via `HeadlessPrompter` for CI and scripted installs. The bundled output (`dist/mosaic-wizard.mjs`) is committed to the repo so installs work without `node_modules`.
## Re-installing / Updating
Pull the latest and re-run the installer:
```bash
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`, `USER.md`, `TOOLS.md`, and `memory/`
- `overwrite`: replace everything in `~/.config/mosaic`
Or use the one-liner again — it always pulls the latest:
```bash
curl -sL https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.sh | sh
``` ```

View File

@@ -1,12 +1,32 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# ─── Mosaic Framework Installer ──────────────────────────────────────────────
#
# Installs/upgrades the framework DATA to ~/.config/mosaic/.
# No executables are placed on PATH — the mosaic npm CLI is the only binary.
#
# Called by tools/install.sh (the unified installer). Can also be run directly.
#
# Environment:
# MOSAIC_HOME — target directory (default: ~/.config/mosaic)
# MOSAIC_INSTALL_MODE — prompt|keep|overwrite (default: prompt)
# MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING — 1 to bypass MCP check
# MOSAIC_SKIP_SKILLS_SYNC — 1 to skip skill sync
# ──────────────────────────────────────────────────────────────────────────────
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}" TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}" # prompt|keep|overwrite INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
PRESERVE_PATHS=("SOUL.md" "USER.md" "TOOLS.md" "memory")
# Colors (disabled if not a terminal) # Files preserved across upgrades (never overwritten)
PRESERVE_PATHS=("SOUL.md" "USER.md" "TOOLS.md" "memory" "sources")
# Current framework schema version — bump this when the layout changes.
# The migration system uses this to run upgrade steps.
FRAMEWORK_VERSION=2
# ─── colours ──────────────────────────────────────────────────────────────────
if [[ -t 1 ]]; then if [[ -t 1 ]]; then
GREEN='\033[0;32m' YELLOW='\033[0;33m' RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' RED='\033[0;31m'
CYAN='\033[0;36m' BOLD='\033[1m' RESET='\033[0m' CYAN='\033[0;36m' BOLD='\033[1m' RESET='\033[0m'
@@ -19,9 +39,29 @@ warn() { echo -e " ${YELLOW}⚠${RESET} $1" >&2; }
fail() { echo -e " ${RED}${RESET} $1" >&2; } fail() { echo -e " ${RED}${RESET} $1" >&2; }
step() { echo -e "\n${BOLD}$1${RESET}"; } step() { echo -e "\n${BOLD}$1${RESET}"; }
# ─── helpers ──────────────────────────────────────────────────────────────────
is_existing_install() { is_existing_install() {
[[ -d "$TARGET_DIR" ]] || return 1 [[ -d "$TARGET_DIR" ]] || return 1
[[ -f "$TARGET_DIR/bin/mosaic" || -f "$TARGET_DIR/AGENTS.md" || -f "$TARGET_DIR/SOUL.md" ]] [[ -f "$TARGET_DIR/AGENTS.md" || -f "$TARGET_DIR/SOUL.md" ]]
}
installed_framework_version() {
local vf="$TARGET_DIR/.framework-version"
if [[ -f "$vf" ]]; then
cat "$vf" 2>/dev/null || echo "0"
else
# No version file = legacy install (version 0 or 1)
if [[ -d "$TARGET_DIR/bin" ]]; then
echo "1" # Has bin/ → pre-migration legacy
else
echo "0" # Fresh or unknown
fi
fi
}
write_framework_version() {
echo "$FRAMEWORK_VERSION" > "$TARGET_DIR/.framework-version"
} }
select_install_mode() { select_install_mode() {
@@ -39,33 +79,22 @@ select_install_mode() {
fi fi
case "$INSTALL_MODE" in case "$INSTALL_MODE" in
keep|overwrite) keep|overwrite) ;;
;;
prompt) prompt)
if [[ -t 0 ]]; then if [[ -t 0 ]]; then
echo "" echo ""
echo "Existing Mosaic install detected at: $TARGET_DIR" echo "Existing Mosaic install detected at: $TARGET_DIR"
echo "Choose reinstall mode:" echo " 1) keep Update framework, preserve local files (SOUL.md, USER.md, etc.)"
echo " 1) keep Keep local files (SOUL.md, USER.md, TOOLS.md, memory/) while updating framework" echo " 2) overwrite Replace everything"
echo " 2) overwrite Replace everything in $TARGET_DIR" echo " 3) cancel Abort"
echo " 3) cancel Abort install"
printf "Selection [1/2/3] (default: 1): " printf "Selection [1/2/3] (default: 1): "
read -r selection read -r selection
case "${selection:-1}" in case "${selection:-1}" in
1|k|K|keep|KEEP) INSTALL_MODE="keep" ;; 1|k|K|keep) INSTALL_MODE="keep" ;;
2|o|O|overwrite|OVERWRITE) INSTALL_MODE="overwrite" ;; 2|o|O|overwrite) INSTALL_MODE="overwrite" ;;
3|c|C|cancel|CANCEL|n|N|no|NO) *) fail "Install cancelled."; exit 1 ;;
fail "Install cancelled."
exit 1
;;
*)
warn "Unrecognized selection '$selection'; defaulting to keep."
INSTALL_MODE="keep"
;;
esac esac
else else
warn "Existing install detected without interactive input; defaulting to keep local files."
INSTALL_MODE="keep" INSTALL_MODE="keep"
fi fi
;; ;;
@@ -83,10 +112,9 @@ sync_framework() {
fi fi
if command -v rsync >/dev/null 2>&1; then if command -v rsync >/dev/null 2>&1; then
local rsync_args=(-a --delete --exclude ".git") local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version")
if [[ "$INSTALL_MODE" == "keep" ]]; then if [[ "$INSTALL_MODE" == "keep" ]]; then
local path
for path in "${PRESERVE_PATHS[@]}"; do for path in "${PRESERVE_PATHS[@]}"; do
rsync_args+=(--exclude "$path") rsync_args+=(--exclude "$path")
done done
@@ -96,10 +124,10 @@ sync_framework() {
return return
fi fi
# Fallback: cp-based sync
local preserve_tmp="" local preserve_tmp=""
if [[ "$INSTALL_MODE" == "keep" ]]; then if [[ "$INSTALL_MODE" == "keep" ]]; then
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")" preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
local path
for path in "${PRESERVE_PATHS[@]}"; do for path in "${PRESERVE_PATHS[@]}"; do
if [[ -e "$TARGET_DIR/$path" ]]; then if [[ -e "$TARGET_DIR/$path" ]]; then
mkdir -p "$preserve_tmp/$(dirname "$path")" mkdir -p "$preserve_tmp/$(dirname "$path")"
@@ -108,12 +136,11 @@ sync_framework() {
done done
fi fi
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" -exec rm -rf {} + find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" -exec rm -rf {} +
cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/ cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
rm -rf "$TARGET_DIR/.git" rm -rf "$TARGET_DIR/.git"
if [[ -n "$preserve_tmp" ]]; then if [[ -n "$preserve_tmp" ]]; then
local path
for path in "${PRESERVE_PATHS[@]}"; do for path in "${PRESERVE_PATHS[@]}"; do
if [[ -e "$preserve_tmp/$path" ]]; then if [[ -e "$preserve_tmp/$path" ]]; then
rm -rf "$TARGET_DIR/$path" rm -rf "$TARGET_DIR/$path"
@@ -125,136 +152,133 @@ sync_framework() {
fi fi
} }
# ═══════════════════════════════════════════════════════════════════════════════
# Migrations — run sequentially from the installed version to FRAMEWORK_VERSION
# ═══════════════════════════════════════════════════════════════════════════════
run_migrations() {
local from_version
from_version="$(installed_framework_version)"
if [[ "$from_version" -ge "$FRAMEWORK_VERSION" ]]; then
return # Already current
fi
step "Running migrations (v${from_version} → v${FRAMEWORK_VERSION})"
# ── Migration: v0/v1 → v2 ─────────────────────────────────────────────────
# Remove bin/ directory — all executables now live in the npm CLI.
# Scripts that were in bin/ are now in tools/_scripts/.
if [[ "$from_version" -lt 2 ]]; then
if [[ -d "$TARGET_DIR/bin" ]]; then
ok "Removing legacy bin/ directory (executables now in npm CLI)"
rm -rf "$TARGET_DIR/bin"
fi
# Remove old mosaic PATH entry from shell profiles
for profile in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do
if [[ -f "$profile" ]] && grep -qF "$TARGET_DIR/bin" "$profile"; then
# Remove the PATH line and the comment above it
sed -i.mosaic-migration-bak \
-e "\|# Mosaic agent framework|d" \
-e "\|$TARGET_DIR/bin|d" \
"$profile"
ok "Cleaned up old PATH entry from $(basename "$profile")"
rm -f "${profile}.mosaic-migration-bak"
fi
done
# Remove stale rails/ symlink
if [[ -L "$TARGET_DIR/rails" ]]; then
rm -f "$TARGET_DIR/rails"
fi
fi
# ── Future migrations go here ──────────────────────────────────────────────
# if [[ "$from_version" -lt 3 ]]; then
# ...
# fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# Main
# ═══════════════════════════════════════════════════════════════════════════════
step "Installing Mosaic framework" step "Installing Mosaic framework"
mkdir -p "$TARGET_DIR" mkdir -p "$TARGET_DIR"
select_install_mode select_install_mode
if [[ "$INSTALL_MODE" == "keep" ]]; then if [[ "$INSTALL_MODE" == "keep" ]]; then
ok "Install mode: keep local SOUL.md/USER.md/TOOLS.md/memory while updating framework" ok "Install mode: keep local files (SOUL.md, USER.md, TOOLS.md, memory/)"
else else
ok "Install mode: overwrite existing files" ok "Install mode: overwrite"
fi fi
sync_framework sync_framework
# Ensure memory directory exists (preserved across upgrades, may not exist on fresh install) # Ensure memory directory exists
mkdir -p "$TARGET_DIR/memory" mkdir -p "$TARGET_DIR/memory"
chmod +x "$TARGET_DIR"/bin/*
chmod +x "$TARGET_DIR"/install.sh
# Ensure tool scripts are executable # Ensure tool scripts are executable
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
# Create backward-compat symlink: rails/ → tools/ ok "Framework synced to $TARGET_DIR"
if [[ -d "$TARGET_DIR/tools" ]]; then
if [[ -d "$TARGET_DIR/rails" ]] && [[ ! -L "$TARGET_DIR/rails" ]]; then
rm -rf "$TARGET_DIR/rails"
fi
ln -sfn "tools" "$TARGET_DIR/rails"
fi
ok "Framework installed to $TARGET_DIR" # Run migrations before post-install (migrations may remove old bin/ etc.)
run_migrations
step "Post-install tasks" step "Post-install tasks"
if "$TARGET_DIR/bin/mosaic-link-runtime-assets" >/dev/null 2>&1; then SCRIPTS="$TARGET_DIR/tools/_scripts"
ok "Runtime assets linked"
else
warn "Runtime asset linking failed (non-fatal)"
fi
if "$TARGET_DIR/bin/mosaic-ensure-sequential-thinking" >/dev/null 2>&1; then if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
ok "sequential-thinking MCP configured" if "$SCRIPTS/mosaic-link-runtime-assets" >/dev/null 2>&1; then
else ok "Runtime assets linked"
if [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
warn "sequential-thinking MCP setup failed but bypassed (MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1)"
else else
fail "sequential-thinking MCP setup failed (hard requirement)." warn "Runtime asset linking failed (non-fatal)"
fail "Set MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1 only for temporary bypass scenarios."
exit 1
fi fi
fi fi
if "$TARGET_DIR/bin/mosaic-ensure-excalidraw" >/dev/null 2>&1; then if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then
ok "excalidraw MCP configured" if "$SCRIPTS/mosaic-ensure-sequential-thinking" >/dev/null 2>&1; then
else ok "sequential-thinking MCP configured"
warn "excalidraw MCP setup failed (non-fatal) — run 'mosaic-ensure-excalidraw' to retry"
fi
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" == "1" ]]; then
ok "Skills sync skipped (MOSAIC_SKIP_SKILLS_SYNC=1)"
else
if "$TARGET_DIR/bin/mosaic-sync-skills" >/dev/null 2>&1; then
ok "Skills synced"
else else
warn "Skills sync failed (non-fatal)" if [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
warn "sequential-thinking MCP setup bypassed (MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1)"
else
fail "sequential-thinking MCP setup failed (hard requirement)."
exit 1
fi
fi fi
fi fi
if "$TARGET_DIR/bin/mosaic-migrate-local-skills" --apply >/dev/null 2>&1; then if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then
ok "Local skills migrated" "$SCRIPTS/mosaic-ensure-excalidraw" >/dev/null 2>&1 && ok "excalidraw MCP configured" || warn "excalidraw MCP setup failed (non-fatal)"
else
warn "Local skill migration failed (non-fatal)"
fi fi
if "$TARGET_DIR/bin/mosaic-doctor" >/dev/null 2>&1; then if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" != "1" ]] && [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
ok "Health audit passed" "$SCRIPTS/mosaic-sync-skills" >/dev/null 2>&1 && ok "Skills synced" || warn "Skills sync failed (non-fatal)"
else
warn "Health audit reported issues — run 'mosaic doctor' for details"
fi fi
step "PATH configuration" if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then
"$SCRIPTS/mosaic-migrate-local-skills" --apply >/dev/null 2>&1 && ok "Local skills migrated" || warn "Local skill migration failed (non-fatal)"
PATH_LINE="export PATH=\"$TARGET_DIR/bin:\$PATH\""
# Find the right shell profile
if [[ -n "${ZSH_VERSION:-}" ]] || [[ "$(basename "${SHELL:-}")" == "zsh" ]]; then
SHELL_PROFILE="$HOME/.zshrc"
elif [[ -f "$HOME/.bashrc" ]]; then
SHELL_PROFILE="$HOME/.bashrc"
elif [[ -f "$HOME/.profile" ]]; then
SHELL_PROFILE="$HOME/.profile"
else
SHELL_PROFILE="$HOME/.profile"
fi fi
PATH_CHANGED=false if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then
if grep -qF "$TARGET_DIR/bin" "$SHELL_PROFILE" 2>/dev/null; then "$SCRIPTS/mosaic-doctor" >/dev/null 2>&1 && ok "Health audit passed" || warn "Health audit reported issues — run 'mosaic doctor' for details"
ok "Already in PATH via $SHELL_PROFILE"
else
{
echo ""
echo "# Mosaic agent framework"
echo "$PATH_LINE"
} >> "$SHELL_PROFILE"
ok "Added to PATH in $SHELL_PROFILE"
PATH_CHANGED=true
fi fi
# Write version stamp AFTER everything succeeds
write_framework_version
# ── Summary ────────────────────────────────────────────────── # ── Summary ──────────────────────────────────────────────────
echo "" echo ""
echo -e "${GREEN}${BOLD} Mosaic installed successfully.${RESET}" echo -e "${GREEN}${BOLD} Mosaic framework installed.${RESET}"
echo "" echo ""
# Collect next steps
NEXT_STEPS=()
if [[ "$PATH_CHANGED" == "true" ]]; then
NEXT_STEPS+=("Run ${CYAN}source $SHELL_PROFILE${RESET} or log out and back in to activate PATH.")
fi
if [[ ! -f "$TARGET_DIR/SOUL.md" ]]; then if [[ ! -f "$TARGET_DIR/SOUL.md" ]]; then
NEXT_STEPS+=("Run ${CYAN}mosaic init${RESET} to set up your agent identity (SOUL.md), user profile (USER.md), and tool config (TOOLS.md).") echo -e " Run ${CYAN}mosaic init${RESET} to set up your agent identity."
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
echo -e " ${BOLD}Next steps:${RESET}"
for i in "${!NEXT_STEPS[@]}"; do
echo -e " $((i+1)). ${NEXT_STEPS[$i]}"
done
echo "" echo ""
fi fi

View File

@@ -1,38 +0,0 @@
# Mosaic Bootstrap — Remote Installer (Windows PowerShell)
#
# One-liner:
# irm https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.ps1 | iex
#
# Or explicit:
# powershell -ExecutionPolicy Bypass -File remote-install.ps1
#
$ErrorActionPreference = "Stop"
$BootstrapRef = if ($env:MOSAIC_BOOTSTRAP_REF) { $env:MOSAIC_BOOTSTRAP_REF } else { "main" }
$ArchiveUrl = "https://git.mosaicstack.dev/mosaic/bootstrap/archive/$BootstrapRef.zip"
$WorkDir = Join-Path $env:TEMP "mosaic-bootstrap-$PID"
$ZipPath = "$WorkDir.zip"
try {
Write-Host "[mosaic] Downloading bootstrap archive (ref: $BootstrapRef)..."
New-Item -ItemType Directory -Path $WorkDir -Force | Out-Null
Invoke-WebRequest -Uri $ArchiveUrl -OutFile $ZipPath -UseBasicParsing
Write-Host "[mosaic] Extracting..."
Expand-Archive -Path $ZipPath -DestinationPath $WorkDir -Force
$InstallScript = Join-Path $WorkDir "bootstrap\install.ps1"
if (-not (Test-Path $InstallScript)) {
throw "install.ps1 not found in archive"
}
Write-Host "[mosaic] Running install..."
& $InstallScript
Write-Host "[mosaic] Done."
}
finally {
Write-Host "[mosaic] Cleaning up temporary files..."
Remove-Item -Path $ZipPath -Force -ErrorAction SilentlyContinue
Remove-Item -Path $WorkDir -Recurse -Force -ErrorAction SilentlyContinue
}

View File

@@ -1,63 +0,0 @@
#!/usr/bin/env sh
# Mosaic Bootstrap — Remote Installer (POSIX)
#
# One-liner:
# curl -sL https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.sh | sh
#
# Or with wget:
# wget -qO- https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.sh | sh
#
set -eu
BOOTSTRAP_REF="${MOSAIC_BOOTSTRAP_REF:-main}"
ARCHIVE_URL="https://git.mosaicstack.dev/mosaic/bootstrap/archive/${BOOTSTRAP_REF}.tar.gz"
TMPDIR_BASE="${TMPDIR:-/tmp}"
WORK_DIR="$TMPDIR_BASE/mosaic-bootstrap-$$"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
echo "[mosaic] Downloading bootstrap archive (ref: $BOOTSTRAP_REF)..."
mkdir -p "$WORK_DIR"
if command -v curl >/dev/null 2>&1; then
curl -sL "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
elif command -v wget >/dev/null 2>&1; then
wget -qO- "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
else
echo "[mosaic] ERROR: curl or wget required" >&2
exit 1
fi
if [ ! -f "$WORK_DIR/bootstrap/install.sh" ]; then
echo "[mosaic] ERROR: install.sh not found in archive" >&2
exit 1
fi
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
if [ -e /dev/tty ]; then
echo "[mosaic] Running wizard installer (Node.js $NODE_MAJOR detected)..."
node "$WIZARD_BIN" --source-dir "$WORK_DIR/bootstrap" </dev/tty
else
echo "[mosaic] Running wizard installer in non-interactive mode (no TTY)..."
node "$WIZARD_BIN" --source-dir "$WORK_DIR/bootstrap" --non-interactive
fi
echo "[mosaic] Cleaning up temporary files..."
exit 0
fi
fi
echo "[mosaic] Running legacy install..."
bash install.sh </dev/tty
echo "[mosaic] Cleaning up temporary files..."
# cleanup runs via trap

View File

@@ -69,12 +69,12 @@ case "$cmd" in
echo "[agent-framework] orchestrator already running (pid=$(cat "$PID_FILE"))" echo "[agent-framework] orchestrator already running (pid=$(cat "$PID_FILE"))"
exit 0 exit 0
fi fi
nohup "$MOSAIC_HOME/bin/mosaic-orchestrator-drain" --poll-sec "$poll_sec" $sync_arg >"$LOG_FILE" 2>&1 & nohup "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-drain" --poll-sec "$poll_sec" $sync_arg >"$LOG_FILE" 2>&1 &
echo "$!" > "$PID_FILE" echo "$!" > "$PID_FILE"
echo "[agent-framework] orchestrator started (pid=$!, log=$LOG_FILE)" echo "[agent-framework] orchestrator started (pid=$!, log=$LOG_FILE)"
;; ;;
drain) drain)
exec "$MOSAIC_HOME/bin/mosaic-orchestrator-drain" --poll-sec "$poll_sec" $sync_arg exec "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-drain" --poll-sec "$poll_sec" $sync_arg
;; ;;
stop) stop)
if ! is_running; then if ! is_running; then

View File

@@ -113,8 +113,8 @@ echo "[mosaic] Optional: run orchestrator rail via ~/.config/mosaic/bin/mosaic-o
echo "[mosaic] Optional: run detached orchestrator via bash $TARGET_DIR/scripts/agent/orchestrator-daemon.sh start" echo "[mosaic] Optional: run detached orchestrator via bash $TARGET_DIR/scripts/agent/orchestrator-daemon.sh start"
if [[ -n "$QUALITY_TEMPLATE" ]]; then if [[ -n "$QUALITY_TEMPLATE" ]]; then
if [[ -x "$MOSAIC_HOME/bin/mosaic-quality-apply" ]]; then if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-quality-apply" ]]; then
"$MOSAIC_HOME/bin/mosaic-quality-apply" --template "$QUALITY_TEMPLATE" --target "$TARGET_DIR" "$MOSAIC_HOME/tools/_scripts/mosaic-quality-apply" --template "$QUALITY_TEMPLATE" --target "$TARGET_DIR"
if [[ -f "$TARGET_DIR/.mosaic/quality-rails.yml" ]]; then if [[ -f "$TARGET_DIR/.mosaic/quality-rails.yml" ]]; then
sed -i "s/^enabled:.*/enabled: true/" "$TARGET_DIR/.mosaic/quality-rails.yml" sed -i "s/^enabled:.*/enabled: true/" "$TARGET_DIR/.mosaic/quality-rails.yml"
sed -i "s/^template:.*/template: \"$QUALITY_TEMPLATE\"/" "$TARGET_DIR/.mosaic/quality-rails.yml" sed -i "s/^template:.*/template: \"$QUALITY_TEMPLATE\"/" "$TARGET_DIR/.mosaic/quality-rails.yml"

View File

@@ -165,18 +165,18 @@ expect_dir "$MOSAIC_HOME/profiles"
expect_dir "$MOSAIC_HOME/templates/agent" expect_dir "$MOSAIC_HOME/templates/agent"
expect_dir "$MOSAIC_HOME/skills" expect_dir "$MOSAIC_HOME/skills"
expect_dir "$MOSAIC_HOME/skills-local" expect_dir "$MOSAIC_HOME/skills-local"
expect_file "$MOSAIC_HOME/bin/mosaic-link-runtime-assets" expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets"
expect_file "$MOSAIC_HOME/bin/mosaic-ensure-sequential-thinking" expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-ensure-sequential-thinking"
expect_file "$MOSAIC_HOME/bin/mosaic-sync-skills" expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-sync-skills"
expect_file "$MOSAIC_HOME/bin/mosaic-projects" expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-projects"
expect_file "$MOSAIC_HOME/bin/mosaic-quality-apply" expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-quality-apply"
expect_file "$MOSAIC_HOME/bin/mosaic-quality-verify" expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-quality-verify"
expect_file "$MOSAIC_HOME/bin/mosaic-orchestrator-run" expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-run"
expect_file "$MOSAIC_HOME/bin/mosaic-orchestrator-sync-tasks" expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-sync-tasks"
expect_file "$MOSAIC_HOME/bin/mosaic-orchestrator-drain" expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-drain"
expect_file "$MOSAIC_HOME/bin/mosaic-orchestrator-matrix-publish" expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-matrix-publish"
expect_file "$MOSAIC_HOME/bin/mosaic-orchestrator-matrix-consume" expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-matrix-consume"
expect_file "$MOSAIC_HOME/bin/mosaic-orchestrator-matrix-cycle" expect_file "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-matrix-cycle"
expect_file "$MOSAIC_HOME/tools/git/ci-queue-wait.sh" expect_file "$MOSAIC_HOME/tools/git/ci-queue-wait.sh"
expect_file "$MOSAIC_HOME/tools/git/pr-ci-wait.sh" expect_file "$MOSAIC_HOME/tools/git/pr-ci-wait.sh"
expect_file "$MOSAIC_HOME/tools/orchestrator-matrix/transport/matrix_transport.py" expect_file "$MOSAIC_HOME/tools/orchestrator-matrix/transport/matrix_transport.py"
@@ -215,8 +215,8 @@ check_runtime_contract_file "$HOME/.config/opencode/AGENTS.md" "$MOSAIC_HOME/run
check_runtime_contract_file "$HOME/.codex/instructions.md" "$MOSAIC_HOME/runtime/codex/instructions.md" "codex" check_runtime_contract_file "$HOME/.codex/instructions.md" "$MOSAIC_HOME/runtime/codex/instructions.md" "codex"
# Sequential-thinking MCP hard requirement. # Sequential-thinking MCP hard requirement.
if [[ -x "$MOSAIC_HOME/bin/mosaic-ensure-sequential-thinking" ]]; then if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-ensure-sequential-thinking" ]]; then
if "$MOSAIC_HOME/bin/mosaic-ensure-sequential-thinking" --check >/dev/null 2>&1; then if "$MOSAIC_HOME/tools/_scripts/mosaic-ensure-sequential-thinking" --check >/dev/null 2>&1; then
pass "sequential-thinking MCP configured and available" pass "sequential-thinking MCP configured and available"
else else
warn "sequential-thinking MCP missing or misconfigured" warn "sequential-thinking MCP missing or misconfigured"
@@ -422,8 +422,8 @@ with open('$pi_settings_file', 'w') as f:
fi fi
# 4. Run link-runtime-assets if available # 4. Run link-runtime-assets if available
if [[ -x "$MOSAIC_HOME/bin/mosaic-link-runtime-assets" ]]; then if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets" ]]; then
"$MOSAIC_HOME/bin/mosaic-link-runtime-assets" >/dev/null 2>&1 && fix "Re-ran mosaic-link-runtime-assets" "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets" >/dev/null 2>&1 && fix "Re-ran mosaic-link-runtime-assets"
fi fi
echo "[mosaic-doctor] fixes=$fix_count" echo "[mosaic-doctor] fixes=$fix_count"

View File

@@ -60,12 +60,14 @@ Options:
--timezone <tz> Your timezone (e.g., "America/Chicago") --timezone <tz> Your timezone (e.g., "America/Chicago")
--non-interactive Fail if any required value is missing (no prompts) --non-interactive Fail if any required value is missing (no prompts)
--soul-only Only generate SOUL.md --soul-only Only generate SOUL.md
--force Overwrite existing files without prompting
-h, --help Show help -h, --help Show help
USAGE USAGE
} }
NON_INTERACTIVE=0 NON_INTERACTIVE=0
SOUL_ONLY=0 SOUL_ONLY=0
FORCE=0
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@@ -79,6 +81,7 @@ while [[ $# -gt 0 ]]; do
--timezone) TIMEZONE="$2"; shift 2 ;; --timezone) TIMEZONE="$2"; shift 2 ;;
--non-interactive) NON_INTERACTIVE=1; shift ;; --non-interactive) NON_INTERACTIVE=1; shift ;;
--soul-only) SOUL_ONLY=1; shift ;; --soul-only) SOUL_ONLY=1; shift ;;
--force) FORCE=1; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;; *) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;;
esac esac
@@ -139,6 +142,134 @@ prompt_multiline() {
eval "$var_name=\"$value\"" eval "$var_name=\"$value\""
} }
# ── Existing file detection ────────────────────────────────────
detect_existing_config() {
local found=0
local existing_files=()
[[ -f "$SOUL_OUTPUT" ]] && { found=1; existing_files+=("SOUL.md"); }
[[ -f "$USER_OUTPUT" ]] && { found=1; existing_files+=("USER.md"); }
[[ -f "$TOOLS_OUTPUT" ]] && { found=1; existing_files+=("TOOLS.md"); }
if [[ $found -eq 0 || $FORCE -eq 1 ]]; then
return 0 # No existing files or --force: proceed with fresh install
fi
echo "[mosaic-init] Existing configuration detected:"
for f in "${existing_files[@]}"; do
echo " ✓ $f"
done
# Show current agent name if SOUL.md exists
if [[ -f "$SOUL_OUTPUT" ]]; then
local current_name
current_name=$(grep -oP 'You are \*\*\K[^*]+' "$SOUL_OUTPUT" 2>/dev/null || true)
if [[ -n "$current_name" ]]; then
echo " Agent: $current_name"
fi
fi
echo ""
if [[ $NON_INTERACTIVE -eq 1 ]]; then
echo "[mosaic-init] Existing config found. Use --force to overwrite in non-interactive mode."
exit 0
fi
echo "What would you like to do?"
echo " 1) keep — Keep existing files, skip init (default)"
echo " 2) import — Import values from existing files as defaults, then regenerate"
echo " 3) overwrite — Start fresh, overwrite all files"
printf "Choose [1/2/3]: "
read -r choice
case "${choice:-1}" in
1|keep)
echo "[mosaic-init] Keeping existing configuration."
# Still push to runtime adapters in case framework was updated
if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets" ]]; then
echo "[mosaic-init] Updating runtime adapters..."
"$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets"
fi
echo "[mosaic-init] Done. Launch with: mosaic claude"
exit 0
;;
2|import)
echo "[mosaic-init] Importing values from existing files as defaults..."
import_existing_values
;;
3|overwrite)
echo "[mosaic-init] Starting fresh install..."
# Back up existing files
local ts
ts=$(date +%Y%m%d%H%M%S)
for f in "${existing_files[@]}"; do
local src="$MOSAIC_HOME/$f"
if [[ -f "$src" ]]; then
cp "$src" "${src}.bak.${ts}"
echo " Backed up $f → ${f}.bak.${ts}"
fi
done
;;
*)
echo "[mosaic-init] Invalid choice. Keeping existing configuration."
exit 0
;;
esac
}
import_existing_values() {
# Import SOUL.md values
if [[ -f "$SOUL_OUTPUT" ]]; then
local content
content=$(cat "$SOUL_OUTPUT")
if [[ -z "$AGENT_NAME" ]]; then
AGENT_NAME=$(echo "$content" | grep -oP 'You are \*\*\K[^*]+' 2>/dev/null || true)
fi
if [[ -z "$ROLE_DESCRIPTION" ]]; then
ROLE_DESCRIPTION=$(echo "$content" | grep -oP 'Role identity: \K.+' 2>/dev/null || true)
fi
if [[ -z "$STYLE" ]]; then
if echo "$content" | grep -q 'Be direct, concise'; then
STYLE="direct"
elif echo "$content" | grep -q 'Be warm and conversational'; then
STYLE="friendly"
elif echo "$content" | grep -q 'Use professional, structured'; then
STYLE="formal"
fi
fi
fi
# Import USER.md values
if [[ -f "$USER_OUTPUT" ]]; then
local content
content=$(cat "$USER_OUTPUT")
if [[ -z "$USER_NAME" ]]; then
USER_NAME=$(echo "$content" | grep -oP '\*\*Name:\*\* \K.+' 2>/dev/null || true)
fi
if [[ -z "$PRONOUNS" ]]; then
PRONOUNS=$(echo "$content" | grep -oP '\*\*Pronouns:\*\* \K.+' 2>/dev/null || true)
fi
if [[ -z "$TIMEZONE" ]]; then
TIMEZONE=$(echo "$content" | grep -oP '\*\*Timezone:\*\* \K.+' 2>/dev/null || true)
fi
fi
# Import TOOLS.md values
if [[ -f "$TOOLS_OUTPUT" ]]; then
local content
content=$(cat "$TOOLS_OUTPUT")
if [[ -z "$CREDENTIALS_LOCATION" ]]; then
CREDENTIALS_LOCATION=$(echo "$content" | grep -oP '\*\*Location:\*\* \K.+' 2>/dev/null || true)
fi
fi
}
detect_existing_config
# ── SOUL.md Generation ──────────────────────────────────────── # ── SOUL.md Generation ────────────────────────────────────────
echo "[mosaic-init] Generating SOUL.md — agent identity contract" echo "[mosaic-init] Generating SOUL.md — agent identity contract"
echo "" echo ""
@@ -261,9 +392,9 @@ echo "[mosaic-init] Style: $STYLE"
if [[ $SOUL_ONLY -eq 1 ]]; then if [[ $SOUL_ONLY -eq 1 ]]; then
# Push to runtime adapters and exit # Push to runtime adapters and exit
if [[ -x "$MOSAIC_HOME/bin/mosaic-link-runtime-assets" ]]; then if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets" ]]; then
echo "[mosaic-init] Updating runtime adapters..." echo "[mosaic-init] Updating runtime adapters..."
"$MOSAIC_HOME/bin/mosaic-link-runtime-assets" "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets"
fi fi
echo "[mosaic-init] Done. Launch with: mosaic claude" echo "[mosaic-init] Done. Launch with: mosaic claude"
exit 0 exit 0
@@ -413,10 +544,10 @@ fi
# ── Finalize ────────────────────────────────────────────────── # ── Finalize ──────────────────────────────────────────────────
# Push to runtime adapters # Push to runtime adapters
if [[ -x "$MOSAIC_HOME/bin/mosaic-link-runtime-assets" ]]; then if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets" ]]; then
echo "" echo ""
echo "[mosaic-init] Updating runtime adapters..." echo "[mosaic-init] Updating runtime adapters..."
"$MOSAIC_HOME/bin/mosaic-link-runtime-assets" "$MOSAIC_HOME/tools/_scripts/mosaic-link-runtime-assets"
fi fi
echo "" echo ""

View File

@@ -128,8 +128,8 @@ fi
# Pi extension is loaded via --extension flag in the mosaic launcher. # Pi extension is loaded via --extension flag in the mosaic launcher.
# Do NOT copy into ~/.pi/agent/extensions/ — that causes duplicate loading. # Do NOT copy into ~/.pi/agent/extensions/ — that causes duplicate loading.
if [[ -x "$MOSAIC_HOME/bin/mosaic-ensure-sequential-thinking" ]]; then if [[ -x "$MOSAIC_HOME/tools/_scripts/mosaic-ensure-sequential-thinking" ]]; then
"$MOSAIC_HOME/bin/mosaic-ensure-sequential-thinking" "$MOSAIC_HOME/tools/_scripts/mosaic-ensure-sequential-thinking"
fi fi
echo "[mosaic-link] Runtime assets synced (non-symlink mode)" echo "[mosaic-link] Runtime assets synced (non-symlink mode)"

View File

@@ -2,8 +2,8 @@
set -euo pipefail set -euo pipefail
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
sync_cmd="$MOSAIC_HOME/bin/mosaic-orchestrator-sync-tasks" sync_cmd="$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-sync-tasks"
run_cmd="$MOSAIC_HOME/bin/mosaic-orchestrator-run" run_cmd="$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-run"
do_sync=1 do_sync=1
poll_sec=15 poll_sec=15

View File

@@ -3,9 +3,9 @@ set -euo pipefail
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
consume="$MOSAIC_HOME/bin/mosaic-orchestrator-matrix-consume" consume="$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-matrix-consume"
run="$MOSAIC_HOME/bin/mosaic-orchestrator-run" run="$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-run"
publish="$MOSAIC_HOME/bin/mosaic-orchestrator-matrix-publish" publish="$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-matrix-publish"
for cmd in "$consume" "$run" "$publish"; do for cmd in "$consume" "$run" "$publish"; do
if [[ ! -x "$cmd" ]]; then if [[ ! -x "$cmd" ]]; then

View File

@@ -151,7 +151,7 @@ case "$cmd" in
[[ -n "$quality_template" ]] && args+=(--quality-template "$quality_template") [[ -n "$quality_template" ]] && args+=(--quality-template "$quality_template")
args+=("$repo") args+=("$repo")
echo "[mosaic-projects] bootstrap: $repo" echo "[mosaic-projects] bootstrap: $repo"
"$MOSAIC_HOME/bin/mosaic-bootstrap-repo" "${args[@]}" "$MOSAIC_HOME/tools/_scripts/mosaic-bootstrap-repo" "${args[@]}"
add_repo "$repo" || true add_repo "$repo" || true
done done
;; ;;
@@ -193,7 +193,7 @@ case "$cmd" in
drain) drain)
args=(--poll-sec "$poll_sec") args=(--poll-sec "$poll_sec")
[[ $no_sync -eq 1 ]] && args+=(--no-sync) [[ $no_sync -eq 1 ]] && args+=(--no-sync)
"$MOSAIC_HOME/bin/mosaic-orchestrator-drain" "${args[@]}" "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-drain" "${args[@]}"
;; ;;
status) status)
echo "[mosaic-projects] no daemon script in repo; run from bootstrapped repo or re-bootstrap" echo "[mosaic-projects] no daemon script in repo; run from bootstrapped repo or re-bootstrap"

View File

@@ -12,7 +12,7 @@ set -euo pipefail
# mosaic-release-upgrade --ref v0.2.0 --overwrite --yes # mosaic-release-upgrade --ref v0.2.0 --overwrite --yes
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
REMOTE_SCRIPT_URL="${MOSAIC_REMOTE_INSTALL_URL:-https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.sh}" REMOTE_SCRIPT_URL="${MOSAIC_REMOTE_INSTALL_URL:-https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh}"
BOOTSTRAP_REF="${MOSAIC_BOOTSTRAP_REF:-main}" BOOTSTRAP_REF="${MOSAIC_BOOTSTRAP_REF:-main}"
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-keep}" # keep|overwrite INSTALL_MODE="${MOSAIC_INSTALL_MODE:-keep}" # keep|overwrite
YES=false YES=false

View File

@@ -19,7 +19,7 @@ $MosaicHome = if ($env:MOSAIC_HOME) { $env:MOSAIC_HOME } else { Join-Path $env:U
$RemoteInstallerUrl = if ($env:MOSAIC_REMOTE_INSTALL_URL) { $RemoteInstallerUrl = if ($env:MOSAIC_REMOTE_INSTALL_URL) {
$env:MOSAIC_REMOTE_INSTALL_URL $env:MOSAIC_REMOTE_INSTALL_URL
} else { } else {
"https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.ps1" "https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh"
} }
$installMode = if ($Overwrite) { "overwrite" } elseif ($Keep) { "keep" } elseif ($env:MOSAIC_INSTALL_MODE) { $env:MOSAIC_INSTALL_MODE } else { "keep" } $installMode = if ($Overwrite) { "overwrite" } elseif ($Keep) { "keep" } elseif ($env:MOSAIC_INSTALL_MODE) { $env:MOSAIC_INSTALL_MODE } else { "keep" }

View File

@@ -55,7 +55,26 @@ mkdir -p "$MOSAIC_HOME" "$MOSAIC_SKILLS_DIR" "$MOSAIC_LOCAL_SKILLS_DIR"
if [[ $fetch -eq 1 ]]; then if [[ $fetch -eq 1 ]]; then
if [[ -d "$SKILLS_REPO_DIR/.git" ]]; then if [[ -d "$SKILLS_REPO_DIR/.git" ]]; then
echo "[mosaic-skills] Updating skills source: $SKILLS_REPO_DIR" echo "[mosaic-skills] Updating skills source: $SKILLS_REPO_DIR"
git -C "$SKILLS_REPO_DIR" pull --rebase
# Stash any local changes (dirty index or worktree) before pulling
local_changes=0
if ! git -C "$SKILLS_REPO_DIR" diff --quiet 2>/dev/null || \
! git -C "$SKILLS_REPO_DIR" diff --cached --quiet 2>/dev/null; then
local_changes=1
echo "[mosaic-skills] Stashing local changes..."
git -C "$SKILLS_REPO_DIR" stash push -q -m "mosaic-sync-skills auto-stash"
fi
if ! git -C "$SKILLS_REPO_DIR" pull --rebase; then
echo "[mosaic-skills] WARN: pull failed — continuing with existing checkout" >&2
fi
# Restore stashed changes
if [[ $local_changes -eq 1 ]]; then
echo "[mosaic-skills] Restoring local changes..."
git -C "$SKILLS_REPO_DIR" stash pop -q 2>/dev/null || \
echo "[mosaic-skills] WARN: stash pop had conflicts — check $SKILLS_REPO_DIR" >&2
fi
else else
echo "[mosaic-skills] Cloning skills source to: $SKILLS_REPO_DIR" echo "[mosaic-skills] Cloning skills source to: $SKILLS_REPO_DIR"
mkdir -p "$(dirname "$SKILLS_REPO_DIR")" mkdir -p "$(dirname "$SKILLS_REPO_DIR")"

View File

@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
BOOTSTRAP_CMD="$MOSAIC_HOME/bin/mosaic-bootstrap-repo" BOOTSTRAP_CMD="$MOSAIC_HOME/tools/_scripts/mosaic-bootstrap-repo"
roots=("$HOME/src") roots=("$HOME/src")
apply=0 apply=0

View File

@@ -12,7 +12,7 @@ fi
if [[ ! -f "$WIZARD_BIN" ]]; then if [[ ! -f "$WIZARD_BIN" ]]; then
echo "[mosaic-wizard] ERROR: Wizard bundle not found." >&2 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 echo "[mosaic-wizard] Re-install with: npm install -g @mosaic/mosaic" >&2
exit 1 exit 1
fi fi

View File

@@ -80,11 +80,11 @@ echo -e "${C_CYAN}Capsule:${C_RESET} $(next_task_capsule_path "$PROJECT")"
cd "$PROJECT" cd "$PROJECT"
if [[ "$YOLO" == true ]]; then if [[ "$YOLO" == true ]]; then
exec "$MOSAIC_HOME/bin/mosaic" yolo "$runtime" "$launch_prompt" exec mosaic yolo "$runtime" "$launch_prompt"
elif [[ "$runtime" == "claude" ]]; then elif [[ "$runtime" == "claude" ]]; then
exec "$MOSAIC_HOME/bin/mosaic" claude "$launch_prompt" exec mosaic claude "$launch_prompt"
elif [[ "$runtime" == "codex" ]]; then elif [[ "$runtime" == "codex" ]]; then
exec "$MOSAIC_HOME/bin/mosaic" codex "$launch_prompt" exec mosaic codex "$launch_prompt"
fi fi
echo -e "${C_RED}Unsupported coord runtime: $runtime${C_RESET}" >&2 echo -e "${C_RED}Unsupported coord runtime: $runtime${C_RESET}" >&2

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/mosaic", "name": "@mosaic/mosaic",
"version": "0.0.1-alpha.1", "version": "0.0.4",
"description": "Mosaic agent framework — installation wizard and meta package", "description": "Mosaic agent framework — installation wizard and meta package",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -1,7 +1,7 @@
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
export const VERSION = '0.1.0'; export const VERSION = '0.0.2';
export const DEFAULT_MOSAIC_HOME = join(homedir(), '.config', 'mosaic'); export const DEFAULT_MOSAIC_HOME = join(homedir(), '.config', 'mosaic');

View File

@@ -13,6 +13,15 @@ import type { CommunicationStyle } from './types.js';
export { VERSION, DEFAULT_MOSAIC_HOME }; export { VERSION, DEFAULT_MOSAIC_HOME };
export { runWizard } from './wizard.js'; export { runWizard } from './wizard.js';
export {
checkForUpdate,
backgroundUpdateCheck,
formatUpdateNotice,
getInstalledVersion,
getLatestVersion,
semverLt,
} from './runtime/update-checker.js';
export type { UpdateCheckResult } from './runtime/update-checker.js';
export { ClackPrompter } from './prompter/clack-prompter.js'; export { ClackPrompter } from './prompter/clack-prompter.js';
export { HeadlessPrompter } from './prompter/headless-prompter.js'; export { HeadlessPrompter } from './prompter/headless-prompter.js';
export { createConfigService } from './config/config-service.js'; export { createConfigService } from './config/config-service.js';

View File

@@ -9,7 +9,7 @@ import {
unlinkSync, unlinkSync,
statSync, statSync,
} from 'node:fs'; } from 'node:fs';
import { dirname, join, relative } from 'node:path'; import { dirname, join, relative, resolve } from 'node:path';
const MAX_BACKUPS = 3; const MAX_BACKUPS = 3;
@@ -68,6 +68,9 @@ export function syncDirectory(
target: string, target: string,
options: { preserve?: string[]; excludeGit?: boolean } = {}, options: { preserve?: string[]; excludeGit?: boolean } = {},
): void { ): void {
// Guard: source and target are the same directory — nothing to sync
if (resolve(source) === resolve(target)) return;
const preserveSet = new Set(options.preserve ?? []); const preserveSet = new Set(options.preserve ?? []);
// Collect files from source // Collect files from source
@@ -77,9 +80,10 @@ export function syncDirectory(
const stat = statSync(src); const stat = statSync(src);
if (stat.isDirectory()) { if (stat.isDirectory()) {
const relPath = relative(relBase, src); const relPath = relative(relBase, src);
const dirName = relPath.split('/').pop() ?? '';
// Skip .git // Skip any .git directory (top-level or nested, e.g. sources/agent-skills/.git)
if (options.excludeGit && relPath === '.git') return; if (options.excludeGit && (dirName === '.git' || relPath.includes('/.git'))) return;
// Skip preserved paths at top level // Skip preserved paths at top level
if (preserveSet.has(relPath) && existsSync(dest)) return; if (preserveSet.has(relPath) && existsSync(dest)) return;
@@ -91,6 +95,9 @@ export function syncDirectory(
} else { } else {
const relPath = relative(relBase, src); const relPath = relative(relBase, src);
// Skip files inside .git directories
if (options.excludeGit && relPath.includes('/.git/')) return;
// Skip preserved files at top level // Skip preserved files at top level
if (preserveSet.has(relPath) && existsSync(dest)) return; if (preserveSet.has(relPath) && existsSync(dest)) return;

View File

@@ -0,0 +1,238 @@
/**
* Mosaic update checker — compares installed @mosaic/cli version against the
* Gitea npm registry and reports when an upgrade is available.
*
* Used by:
* - CLI startup (non-blocking background check)
* - session-start.sh (via `mosaic update --check`)
* - install.sh (direct invocation)
*
* Design:
* - Result is cached to ~/.cache/mosaic/update-check.json for 1 hour
* - Network call is fire-and-forget with a 5 s timeout
* - Never throws on failure — returns stale/unknown result
*/
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
// ─── Types ──────────────────────────────────────────────────────────────────
export interface UpdateCheckResult {
/** Currently installed version (empty if not found) */
current: string;
/** Latest published version (empty if check failed) */
latest: string;
/** True when a newer version is available */
updateAvailable: boolean;
/** ISO timestamp of this check */
checkedAt: string;
/** Where the latest version was resolved from */
registry: string;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
const CLI_PKG = '@mosaic/cli';
const CACHE_DIR = join(homedir(), '.cache', 'mosaic');
const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const NETWORK_TIMEOUT_MS = 5_000;
// ─── Helpers ────────────────────────────────────────────────────────────────
function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string {
try {
return execSync(`npm ${args}`, {
encoding: 'utf-8',
timeout: timeoutMs,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch {
return '';
}
}
/**
* Rudimentary semver "a < b" — handles pre-release tags.
* Per semver spec: 1.0.0-alpha.1 < 1.0.0 (pre-release has lower precedence).
*/
export function semverLt(a: string, b: string): boolean {
if (!a || !b) return false;
const stripped = (v: string) => v.replace(/^v/, '');
const splitPre = (v: string): [string, string | null] => {
const idx = v.indexOf('-');
return idx === -1 ? [v, null] : [v.slice(0, idx), v.slice(idx + 1)];
};
const sa = stripped(a);
const sb = stripped(b);
const [coreA, preA] = splitPre(sa);
const [coreB, preB] = splitPre(sb);
// Compare core version (major.minor.patch)
const partsA = coreA.split('.').map(Number);
const partsB = coreB.split('.').map(Number);
const len = Math.max(partsA.length, partsB.length);
for (let i = 0; i < len; i++) {
const va = partsA[i] ?? 0;
const vb = partsB[i] ?? 0;
if (va < vb) return true;
if (va > vb) return false;
}
// Core versions are equal — compare pre-release
// No pre-release > any pre-release (1.0.0 > 1.0.0-alpha)
if (preA !== null && preB === null) return true;
if (preA === null && preB !== null) return false;
if (preA === null && preB === null) return false;
// Both have pre-release — compare dot-separated identifiers
const idsA = preA!.split('.');
const idsB = preB!.split('.');
const preLen = Math.max(idsA.length, idsB.length);
for (let i = 0; i < preLen; i++) {
const ia = idsA[i];
const ib = idsB[i];
// Fewer fields = lower precedence
if (ia === undefined && ib !== undefined) return true;
if (ia !== undefined && ib === undefined) return false;
const na = /^\d+$/.test(ia!) ? parseInt(ia!, 10) : null;
const nb = /^\d+$/.test(ib!) ? parseInt(ib!, 10) : null;
// Numeric vs string: numeric < string
if (na !== null && nb !== null) {
if (na < nb) return true;
if (na > nb) return false;
continue;
}
if (na !== null && nb === null) return true;
if (na === null && nb !== null) return false;
// Both strings
if (ia! < ib!) return true;
if (ia! > ib!) return false;
}
return false;
}
// ─── Cache ──────────────────────────────────────────────────────────────────
function readCache(): UpdateCheckResult | null {
try {
if (!existsSync(CACHE_FILE)) return null;
const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as UpdateCheckResult;
const age = Date.now() - new Date(raw.checkedAt).getTime();
if (age > CACHE_TTL_MS) return null;
return raw;
} catch {
return null;
}
}
function writeCache(result: UpdateCheckResult): void {
try {
mkdirSync(CACHE_DIR, { recursive: true });
writeFileSync(CACHE_FILE, JSON.stringify(result, null, 2) + '\n', 'utf-8');
} catch {
// Best-effort — cache is not critical
}
}
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Get the currently installed version of @mosaic/cli.
* Returns empty string if not installed.
*/
export function getInstalledVersion(): string {
// Fast path: check via package.json require chain
try {
const raw = npmExec(`ls -g --depth=0 --json 2>/dev/null`, 3000);
if (raw) {
const data = JSON.parse(raw) as {
dependencies?: Record<string, { version?: string }>;
};
return data?.dependencies?.[CLI_PKG]?.version ?? '';
}
} catch {
// fall through
}
return '';
}
/**
* Fetch the latest published version from the Gitea npm registry.
* Returns empty string on failure.
*/
export function getLatestVersion(): string {
return npmExec(`view ${CLI_PKG} version --registry=${REGISTRY}`);
}
/**
* Perform an update check — uses cache when fresh, otherwise hits registry.
* Never throws.
*/
export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckResult {
if (!options?.skipCache) {
const cached = readCache();
if (cached) return cached;
}
const current = getInstalledVersion();
const latest = getLatestVersion();
const updateAvailable = !!(current && latest && semverLt(current, latest));
const result: UpdateCheckResult = {
current,
latest,
updateAvailable,
checkedAt: new Date().toISOString(),
registry: REGISTRY,
};
writeCache(result);
return result;
}
/**
* Format a human-readable update notice. Returns empty string if up-to-date.
*/
export function formatUpdateNotice(result: UpdateCheckResult): string {
if (!result.updateAvailable) return '';
const lines = [
'',
'╭─────────────────────────────────────────────────╮',
'│ │',
`│ Update available: ${result.current}${result.latest}`.padEnd(50) + '│',
'│ │',
'│ Run: bash tools/install.sh │',
'│ Or: npm i -g @mosaic/cli@latest │',
'│ │',
'╰─────────────────────────────────────────────────╯',
'',
];
return lines.join('\n');
}
/**
* Non-blocking update check that prints a notice to stderr if an update
* is available. Designed to be called at CLI startup without delaying
* the user.
*/
export function backgroundUpdateCheck(): void {
try {
const result = checkForUpdate();
const notice = formatUpdateNotice(result);
if (notice) {
process.stderr.write(notice);
}
} catch {
// Silently ignore — never block the user
}
}

View File

@@ -87,7 +87,9 @@ export async function detectInstallStage(
], ],
}); });
if (state.installAction === 'keep') { if (state.installAction === 'keep' || state.installAction === 'reconfigure') {
// Load existing values — for 'keep' they're final, for 'reconfigure'
// they become pre-populated defaults so the user can tweak them.
state.soul = await config.readSoul(); state.soul = await config.readSoul();
state.user = await config.readUser(); state.user = await config.readUser();
state.tools = await config.readTools(); state.tools = await config.readTools();

View File

@@ -24,6 +24,18 @@ export async function soulSetupStage(p: WizardPrompter, state: WizardState): Pro
return undefined; return undefined;
}, },
}); });
} else if (state.installAction === 'reconfigure') {
// Show existing value as default so the user can accept or change it
state.soul.agentName = await p.text({
message: 'What name should agents use?',
placeholder: state.soul.agentName,
defaultValue: state.soul.agentName,
validate: (v) => {
if (v.length === 0) return 'Name cannot be empty';
if (v.length > 50) return 'Name must be under 50 characters';
return undefined;
},
});
} }
if (state.mode === 'advanced') { if (state.mode === 'advanced') {
@@ -38,7 +50,7 @@ export async function soulSetupStage(p: WizardPrompter, state: WizardState): Pro
state.soul.roleDescription ??= DEFAULTS.roleDescription; state.soul.roleDescription ??= DEFAULTS.roleDescription;
} }
if (!state.soul.communicationStyle) { if (!state.soul.communicationStyle || state.installAction === 'reconfigure') {
state.soul.communicationStyle = await p.select<CommunicationStyle>({ state.soul.communicationStyle = await p.select<CommunicationStyle>({
message: 'Communication style', message: 'Communication style',
options: [ options: [
@@ -46,7 +58,7 @@ export async function soulSetupStage(p: WizardPrompter, state: WizardState): Pro
{ value: 'friendly', label: 'Friendly', hint: 'Warm but efficient, conversational' }, { value: 'friendly', label: 'Friendly', hint: 'Warm but efficient, conversational' },
{ value: 'formal', label: 'Formal', hint: 'Professional, structured, thorough' }, { value: 'formal', label: 'Formal', hint: 'Professional, structured, thorough' },
], ],
initialValue: 'direct', initialValue: state.soul.communicationStyle ?? 'direct',
}); });
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/prdy", "name": "@mosaic/prdy",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { "exports": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/quality-rails", "name": "@mosaic/quality-rails",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/queue", "name": "@mosaic/queue",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { "exports": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/types", "name": "@mosaic/types",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { "exports": {

View File

@@ -99,6 +99,11 @@ export interface SetThinkingPayload {
level: string; level: string;
} }
/** Client request to abort the current agent operation */
export interface AbortPayload {
conversationId: string;
}
/** Socket.IO typed event map: server → client */ /** Socket.IO typed event map: server → client */
export interface ServerToClientEvents { export interface ServerToClientEvents {
'message:ack': (payload: MessageAckPayload) => void; 'message:ack': (payload: MessageAckPayload) => void;
@@ -120,4 +125,5 @@ export interface ClientToServerEvents {
message: (data: ChatMessagePayload) => void; message: (data: ChatMessagePayload) => void;
'set:thinking': (data: SetThinkingPayload) => void; 'set:thinking': (data: SetThinkingPayload) => void;
'command:execute': (data: SlashCommandPayload) => void; 'command:execute': (data: SlashCommandPayload) => void;
abort: (data: AbortPayload) => void;
} }

View File

@@ -11,6 +11,7 @@ export type {
SessionInfoPayload, SessionInfoPayload,
RoutingDecisionInfo, RoutingDecisionInfo,
SetThinkingPayload, SetThinkingPayload,
AbortPayload,
ErrorPayload, ErrorPayload,
ChatMessagePayload, ChatMessagePayload,
ServerToClientEvents, ServerToClientEvents,

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/discord-plugin", "name": "@mosaic/discord-plugin",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { "exports": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/oc-macp-plugin", "name": "@mosaic/oc-macp-plugin",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"description": "OpenClaw ACP runtime backend that routes sessions_spawn(runtime:\"macp\") to the Pi MACP runner.", "description": "OpenClaw ACP runtime backend that routes sessions_spawn(runtime:\"macp\") to the Pi MACP runner.",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/oc-framework-plugin", "name": "@mosaic/oc-framework-plugin",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"description": "Injects Mosaic framework rails, runtime contract, and active mission context into all OpenClaw agent sessions and ACP subagent spawns.", "description": "Injects Mosaic framework rails, runtime contract, and active mission context into all OpenClaw agent sessions and ACP subagent spawns.",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/telegram-plugin", "name": "@mosaic/telegram-plugin",
"version": "0.0.1-alpha.1", "version": "0.0.2",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { "exports": {

View File

@@ -0,0 +1,66 @@
# CLI/TUI Tools Enhancement Scratchpad
## Objective
Add 5 capability areas to the Mosaic CLI/TUI + gateway agent:
1. Web search tools (multi-provider: Brave, DuckDuckGo, Tavily, SearXNG)
2. File edit tool (`fs_edit_file` with targeted text replacement)
3. MCP management TUI commands (`/mcp status`, `/mcp reconnect`, `/mcp servers`)
4. File reference in chat (`@file` syntax + `/attach` command)
5. Implement `/stop` to cancel streaming
## Plan
### 1. Web Search Tools (gateway agent tools)
- Create `apps/gateway/src/agent/tools/search-tools.ts`
- Providers: Brave Search API, DuckDuckGo (HTML scraping), Tavily API, SearXNG (self-hosted)
- Each provider activated by env var (BRAVE_API_KEY, TAVILY_API_KEY, SEARXNG_URL)
- Tools: `web_search` (unified), `web_search_news` (news-specific)
- Export from `apps/gateway/src/agent/tools/index.ts`
- Wire into `agent.service.ts` buildToolsForSandbox
### 2. File Edit Tool (gateway agent tool)
- Add `fs_edit_file` to `apps/gateway/src/agent/tools/file-tools.ts`
- Parameters: path, edits[{oldText, newText}] — same semantics as pi's Edit tool
- Validates uniqueness of each oldText, applies all edits atomically
### 3. MCP Management Commands (TUI + gateway)
- Add gateway endpoints: GET /api/mcp/status, POST /api/mcp/:name/reconnect
- Add TUI gateway-api.ts functions for MCP
- Add gateway slash commands: /mcp (with subcommands status, reconnect, servers)
- Register in command manifest from gateway
- Handle in TUI via gateway command forwarding (already works)
### 4. File Reference in Chat (@file syntax)
- TUI-side: detect @path/to/file in input, read file contents, inline into message
- Add `/attach <path>` local command as alternative
- gateway-api.ts helper not needed — this is purely client-side pre-processing
- Modify InputBar or sendMessage to expand @file references before sending
### 5. Implement /stop
- Add `abort` event to ClientToServerEvents in @mosaic/types
- TUI sends abort event on /stop command
- Gateway chat handler aborts the Pi session prompt
- Update use-socket to support abort
- Wire /stop in app.tsx
## Progress
- [x] 1. Web search tools
- [x] 2. File edit tool
- [x] 3. MCP management commands
- [x] 4. File reference in chat
- [x] 5. Implement /stop
## Risks
- DuckDuckGo has no official API — need HTML scraping or use lite endpoint
- SearXNG needs self-hosted instance
- @file expansion could be large — need size limits
- /stop requires Pi SDK abort support — need to check API

View File

@@ -69,12 +69,12 @@ case "$cmd" in
echo "[agent-framework] orchestrator already running (pid=$(cat "$PID_FILE"))" echo "[agent-framework] orchestrator already running (pid=$(cat "$PID_FILE"))"
exit 0 exit 0
fi fi
nohup "$MOSAIC_HOME/bin/mosaic-orchestrator-drain" --poll-sec "$poll_sec" $sync_arg >"$LOG_FILE" 2>&1 & nohup "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-drain" --poll-sec "$poll_sec" $sync_arg >"$LOG_FILE" 2>&1 &
echo "$!" > "$PID_FILE" echo "$!" > "$PID_FILE"
echo "[agent-framework] orchestrator started (pid=$!, log=$LOG_FILE)" echo "[agent-framework] orchestrator started (pid=$!, log=$LOG_FILE)"
;; ;;
drain) drain)
exec "$MOSAIC_HOME/bin/mosaic-orchestrator-drain" --poll-sec "$poll_sec" $sync_arg exec "$MOSAIC_HOME/tools/_scripts/mosaic-orchestrator-drain" --poll-sec "$poll_sec" $sync_arg
;; ;;
stop) stop)
if ! is_running; then if ! is_running; then

View File

@@ -8,6 +8,18 @@ source "$SCRIPT_DIR/common.sh"
ensure_repo_root ensure_repo_root
load_repo_hooks load_repo_hooks
# ─── Update check (non-blocking) ────────────────────────────────────────────
if command -v mosaic &>/dev/null; then
if mosaic update --check 2>/dev/null; then
: # up to date
elif [[ $? -eq 2 ]]; then
echo ""
echo "[agent-framework] ⚠ A newer version of Mosaic CLI is available."
echo "[agent-framework] Run: mosaic update or bash tools/install.sh"
echo ""
fi
fi
if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && has_remote; then if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && has_remote; then
if git diff --quiet && git diff --cached --quiet; then if git diff --quiet && git diff --cached --quiet; then
run_step "Pull latest changes" git pull --rebase run_step "Pull latest changes" git pull --rebase

318
tools/install.sh Executable file
View File

@@ -0,0 +1,318 @@
#!/usr/bin/env bash
# ─── Mosaic Stack Installer / Upgrader ────────────────────────────────────────
#
# Installs both components:
# 1. Mosaic framework → ~/.config/mosaic/ (bash launcher, guides, runtime configs, tools)
# 2. @mosaic/cli (npm) → ~/.npm-global/ (TUI, gateway client, wizard)
#
# Remote install (recommended):
# bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
#
# Remote install (alternative — use -s -- to pass flags):
# curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh | bash -s --
#
# Flags:
# --check Version check only, no install
# --framework Install/upgrade framework only (skip npm CLI)
# --cli Install/upgrade npm CLI only (skip framework)
# --ref <branch> Git ref for framework archive (default: main)
#
# Environment:
# MOSAIC_HOME — framework install dir (default: ~/.config/mosaic)
# MOSAIC_REGISTRY — npm registry URL (default: Gitea instance)
# MOSAIC_SCOPE — npm scope (default: @mosaic)
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
# MOSAIC_NO_COLOR — disable colour (set to 1)
# MOSAIC_REF — git ref for framework (default: main)
# ──────────────────────────────────────────────────────────────────────────────
#
# Wrapped in main() for safe curl-pipe usage.
set -euo pipefail
main() {
# ─── parse flags ──────────────────────────────────────────────────────────────
FLAG_CHECK=false
FLAG_FRAMEWORK=true
FLAG_CLI=true
GIT_REF="${MOSAIC_REF:-main}"
while [[ $# -gt 0 ]]; do
case "$1" in
--check) FLAG_CHECK=true; shift ;;
--framework) FLAG_CLI=false; shift ;;
--cli) FLAG_FRAMEWORK=false; shift ;;
--ref) GIT_REF="${2:-main}"; shift 2 ;;
*) shift ;;
esac
done
# ─── constants ────────────────────────────────────────────────────────────────
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaic/npm/}"
SCOPE="${MOSAIC_SCOPE:-@mosaic}"
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
CLI_PKG="${SCOPE}/cli"
REPO_BASE="https://git.mosaicstack.dev/mosaic/mosaic-stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
# ─── colours ──────────────────────────────────────────────────────────────────
if [[ "${MOSAIC_NO_COLOR:-0}" == "1" ]] || ! [[ -t 1 ]]; then
R="" G="" Y="" B="" C="" DIM="" BOLD="" RESET=""
else
R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m'
B=$'\033[0;34m' C=$'\033[0;36m' DIM=$'\033[2m'
BOLD=$'\033[1m' RESET=$'\033[0m'
fi
info() { echo "${B}${RESET} $*"; }
ok() { echo "${G}${RESET} $*"; }
warn() { echo "${Y}${RESET} $*"; }
fail() { echo "${R}${RESET} $*" >&2; }
dim() { echo "${DIM}$*${RESET}"; }
step() { echo ""; echo "${BOLD}$*${RESET}"; }
# ─── helpers ──────────────────────────────────────────────────────────────────
require_cmd() {
if ! command -v "$1" &>/dev/null; then
fail "Required command not found: $1"
echo " Install it and re-run this script."
exit 1
fi
}
installed_cli_version() {
local json
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
if [[ -n "$json" ]]; then
node -e "
const d = JSON.parse(process.argv[1]);
const v = d?.dependencies?.['${CLI_PKG}']?.version ?? '';
process.stdout.write(v);
" "$json" 2>/dev/null || true
fi
}
latest_cli_version() {
npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true
}
version_lt() {
node -e "
const a=process.argv[1], b=process.argv[2];
const sp = v => { const i=v.indexOf('-'); return i===-1 ? [v,null] : [v.slice(0,i),v.slice(i+1)]; };
const [cA,pA]=sp(a.replace(/^v/,'')), [cB,pB]=sp(b.replace(/^v/,''));
const nA=cA.split('.').map(Number), nB=cB.split('.').map(Number);
for(let i=0;i<3;i++){if((nA[i]||0)<(nB[i]||0))process.exit(0);if((nA[i]||0)>(nB[i]||0))process.exit(1);}
if(pA!==null&&pB===null)process.exit(0);
if(pA===null)process.exit(1);
if(pA<pB)process.exit(0);
process.exit(1);
" "$1" "$2" 2>/dev/null
}
framework_version() {
# Read framework schema version stamp
local vf="$MOSAIC_HOME/.framework-version"
if [[ -f "$vf" ]]; then
cat "$vf" 2>/dev/null || true
fi
}
# ─── preflight ────────────────────────────────────────────────────────────────
require_cmd node
require_cmd npm
NODE_MAJOR="$(node -e 'process.stdout.write(String(process.versions.node.split(".")[0]))')"
if [[ "$NODE_MAJOR" -lt 20 ]]; then
fail "Node.js >= 20 required (found v$(node --version))"
exit 1
fi
echo ""
echo "${BOLD}Mosaic Stack Installer${RESET}"
echo ""
# ═══════════════════════════════════════════════════════════════════════════════
# PART 1: Framework (bash launcher + guides + runtime configs + tools)
# ═══════════════════════════════════════════════════════════════════════════════
if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
step "Framework (~/.config/mosaic)"
FRAMEWORK_CURRENT="$(framework_version)"
HAS_FRAMEWORK=false
[[ -f "$MOSAIC_HOME/AGENTS.md" ]] || [[ -f "$MOSAIC_HOME/.framework-version" ]] && HAS_FRAMEWORK=true
if [[ -n "$FRAMEWORK_CURRENT" ]]; then
dim " Installed: framework v${FRAMEWORK_CURRENT}"
elif [[ "$HAS_FRAMEWORK" == "true" ]]; then
dim " Installed: framework (version unknown)"
else
dim " Installed: (none)"
fi
dim " Source: ${REPO_BASE} (ref: ${GIT_REF})"
echo ""
if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ "$HAS_FRAMEWORK" == "true" ]]; then
ok "Framework is installed."
else
warn "Framework not installed."
fi
else
# Download repo archive and extract framework
require_cmd tar
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")"
cleanup_work() { rm -rf "$WORK_DIR"; }
trap cleanup_work EXIT
info "Downloading framework from ${GIT_REF}"
if command -v curl &>/dev/null; then
curl -fsSL "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
elif command -v wget &>/dev/null; then
wget -qO- "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
else
fail "curl or wget required to download framework."
exit 1
fi
# Gitea archives extract to <repo-name>/ inside the work dir
EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
FRAMEWORK_SRC="$EXTRACTED_DIR/packages/mosaic/framework"
if [[ ! -d "$FRAMEWORK_SRC" ]]; then
fail "Framework not found in archive at packages/mosaic/framework/"
fail "Archive contents:"
ls -la "$WORK_DIR" >&2
exit 1
fi
# Run the framework's own install.sh (handles keep/overwrite for SOUL.md etc.)
info "Installing framework to ${MOSAIC_HOME}"
MOSAIC_INSTALL_MODE="${MOSAIC_INSTALL_MODE:-keep}" \
MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1 \
MOSAIC_SKIP_SKILLS_SYNC="${MOSAIC_SKIP_SKILLS_SYNC:-0}" \
bash "$FRAMEWORK_SRC/install.sh"
ok "Framework installed"
echo ""
# Framework bin is no longer needed on PATH — the npm CLI delegates
# to mosaic-launch directly via its absolute path.
fi
fi
# ═══════════════════════════════════════════════════════════════════════════════
# PART 2: @mosaic/cli (npm — TUI, gateway client, wizard)
# ═══════════════════════════════════════════════════════════════════════════════
if [[ "$FLAG_CLI" == "true" ]]; then
step "@mosaic/cli (npm package)"
# Ensure prefix dir
if [[ ! -d "$PREFIX" ]]; then
info "Creating global prefix directory: $PREFIX"
mkdir -p "$PREFIX"/{bin,lib}
fi
# Ensure npmrc scope mapping
NPMRC="$HOME/.npmrc"
SCOPE_LINE="${SCOPE}:registry=${REGISTRY}"
if ! grep -qF "$SCOPE_LINE" "$NPMRC" 2>/dev/null; then
info "Adding ${SCOPE} registry to $NPMRC"
echo "$SCOPE_LINE" >> "$NPMRC"
ok "Registry configured"
fi
if ! grep -qF "prefix=$PREFIX" "$NPMRC" 2>/dev/null; then
if ! grep -q '^prefix=' "$NPMRC" 2>/dev/null; then
echo "prefix=$PREFIX" >> "$NPMRC"
info "Set npm global prefix to $PREFIX"
fi
fi
CURRENT="$(installed_cli_version)"
LATEST="$(latest_cli_version)"
if [[ -n "$CURRENT" ]]; then
dim " Installed: ${CLI_PKG}@${CURRENT}"
else
dim " Installed: (none)"
fi
if [[ -n "$LATEST" ]]; then
dim " Latest: ${CLI_PKG}@${LATEST}"
else
dim " Latest: (registry unreachable)"
fi
echo ""
if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ -z "$LATEST" ]]; then
warn "Could not reach registry."
elif [[ -z "$CURRENT" ]]; then
warn "Not installed."
elif [[ "$CURRENT" == "$LATEST" ]]; then
ok "Up to date."
elif version_lt "$CURRENT" "$LATEST"; then
warn "Update available: $CURRENT$LATEST"
else
ok "Up to date (or ahead of registry)."
fi
else
if [[ -z "$LATEST" ]]; then
warn "Could not reach registry at $REGISTRY — skipping npm CLI."
elif [[ -z "$CURRENT" ]]; then
info "Installing ${CLI_PKG}@${LATEST}"
npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'
ok "CLI installed: $(installed_cli_version)"
elif [[ "$CURRENT" == "$LATEST" ]]; then
ok "Already at latest version ($LATEST)."
elif version_lt "$CURRENT" "$LATEST"; then
info "Upgrading ${CLI_PKG}: $CURRENT$LATEST"
npm install -g "${CLI_PKG}@${LATEST}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'
ok "CLI upgraded: $(installed_cli_version)"
else
ok "CLI is at or ahead of registry ($CURRENT$LATEST)."
fi
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
fi
fi
# ═══════════════════════════════════════════════════════════════════════════════
# Summary
# ═══════════════════════════════════════════════════════════════════════════════
if [[ "$FLAG_CHECK" == "false" ]]; then
step "Summary"
echo " ${BOLD}mosaic:${RESET} $PREFIX/bin/mosaic"
dim " Framework data: $MOSAIC_HOME/"
echo ""
# First install guidance
if [[ ! -f "$MOSAIC_HOME/SOUL.md" ]]; then
echo ""
info "First install detected. Set up your agent identity:"
echo " ${C}mosaic init${RESET} (interactive SOUL.md / USER.md setup)"
echo " ${C}mosaic wizard${RESET} (full guided wizard via Node.js)"
fi
echo ""
ok "Done."
fi
} # end main
main "$@"