Compare commits
56 Commits
fix/gitea-
...
release/mo
| Author | SHA1 | Date | |
|---|---|---|---|
| f73cc59b6d | |||
| f64ec12f39 | |||
| 026382325c | |||
| 1bfd8570d6 | |||
| 312acd8bad | |||
| d08b969918 | |||
| 051de0d8a9 | |||
| bd76df1a50 | |||
| 62b2ce2da1 | |||
| 172bacb30f | |||
| 43667d7349 | |||
| 783884376c | |||
| c08aa6fa46 | |||
| 0ae932ab34 | |||
| a8cd52e88c | |||
| a4c94d9a90 | |||
| cee838d22e | |||
| 732f8a49cf | |||
| be917e2496 | |||
| cd8b1f666d | |||
| 8fa5995bde | |||
| 25cada7735 | |||
| be6553101c | |||
| 417805f330 | |||
| 2472ce52e8 | |||
| 597eb232d7 | |||
| afe997db82 | |||
| b9d464de61 | |||
| 872c124581 | |||
| a531029c5b | |||
| 35ab619bd0 | |||
| 831193cdd8 | |||
| df460d5a49 | |||
| 119ff0eb1b | |||
| 3abd63ea5c | |||
| 641e4604d5 | |||
|
|
9b5ecc0171 | ||
|
|
a00325da0e | ||
| 4ebce3422d | |||
| 751e0ee330 | |||
| 54b2920ef3 | |||
| 5917016509 | |||
| 7b4f1d249d | |||
| 5425f9268e | |||
| febd866098 | |||
| 2446593fff | |||
| 651426cf2e | |||
| cf46f6e0ae | |||
| 6f15a84ccf | |||
| c39433c361 | |||
| 257796ce87 | |||
|
|
2357602f50 | ||
| 1230f6b984 | |||
| 14b775f1b9 | |||
|
|
c7691d9807 | ||
| 9a53d55678 |
@@ -35,13 +35,42 @@ steps:
|
|||||||
- |
|
- |
|
||||||
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
||||||
# Publish non-private packages to Gitea (--no-git-checks skips dirty/branch checks in CI)
|
# Publish non-private packages to Gitea.
|
||||||
# --filter excludes web (private)
|
#
|
||||||
- >
|
# The only publish failure we tolerate is "version already exists" —
|
||||||
pnpm --filter "@mosaicstack/*"
|
# that legitimately happens when only some packages were bumped in
|
||||||
--filter "!@mosaicstack/web"
|
# the merge. Any other failure (registry 404, auth error, network
|
||||||
publish --no-git-checks --access public
|
# error) MUST fail the pipeline loudly: the previous
|
||||||
|| echo "[publish] Some packages may already exist at this version — continuing"
|
# `|| echo "... continuing"` fallback silently hid a 404 from the
|
||||||
|
# Gitea org rename and caused every @mosaicstack/* publish to fall
|
||||||
|
# on the floor while CI still reported green.
|
||||||
|
- |
|
||||||
|
# Portable sh (Alpine ash) — avoid bashisms like PIPESTATUS.
|
||||||
|
set +e
|
||||||
|
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" publish --no-git-checks --access public >/tmp/publish.log 2>&1
|
||||||
|
EXIT=$?
|
||||||
|
set -e
|
||||||
|
cat /tmp/publish.log
|
||||||
|
if [ "$EXIT" -eq 0 ]; then
|
||||||
|
echo "[publish] all packages published successfully"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# Hard registry / auth / network errors → fatal. Match npm's own
|
||||||
|
# error lines specifically to avoid false positives on arbitrary
|
||||||
|
# log text that happens to contain "E404" etc.
|
||||||
|
if grep -qE "npm (error|ERR!) code (E404|E401|ENEEDAUTH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND)" /tmp/publish.log; then
|
||||||
|
echo "[publish] FATAL: registry/auth/network error detected — failing pipeline" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Only tolerate the explicit "version already published" case.
|
||||||
|
# npm returns this as E403 with body "You cannot publish over..."
|
||||||
|
# or EPUBLISHCONFLICT depending on version.
|
||||||
|
if grep -qE "EPUBLISHCONFLICT|You cannot publish over|previously published" /tmp/publish.log; then
|
||||||
|
echo "[publish] some packages already at this version — continuing (non-fatal)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "[publish] FATAL: publish failed with unrecognized error — failing pipeline" >&2
|
||||||
|
exit 1
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
|
|
||||||
@@ -74,12 +103,12 @@ steps:
|
|||||||
- mkdir -p /kaniko/.docker
|
- mkdir -p /kaniko/.docker
|
||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||||
- |
|
- |
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:latest"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
||||||
fi
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -99,12 +128,12 @@ steps:
|
|||||||
- mkdir -p /kaniko/.docker
|
- mkdir -p /kaniko/.docker
|
||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||||
- |
|
- |
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:latest"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||||
fi
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Mosaic Stack is a self-hosted, multi-user AI agent platform. TypeScript monorepo
|
|||||||
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
|
| `packages/brain` | Data layer (PG-backed) | @mosaicstack/db |
|
||||||
| `packages/queue` | Valkey task queue + MCP | ioredis |
|
| `packages/queue` | Valkey task queue + MCP | ioredis |
|
||||||
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
| `packages/coord` | Mission coordination | @mosaicstack/queue |
|
||||||
| `packages/cli` | Unified CLI + Pi TUI | Ink, Pi SDK |
|
| `packages/mosaic` | Unified `mosaic` CLI + TUI | Ink, Pi SDK, commander |
|
||||||
| `plugins/discord` | Discord channel plugin | discord.js |
|
| `plugins/discord` | Discord channel plugin | discord.js |
|
||||||
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
| `plugins/telegram` | Telegram channel plugin | Telegraf |
|
||||||
|
|
||||||
@@ -59,9 +59,9 @@ pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
|||||||
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
|
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
|
||||||
|
|
||||||
| Value | When to use | Budget |
|
| Value | When to use | Budget |
|
||||||
| -------- | ----------------------------------------------------------- | -------------------------- |
|
| --------- | ----------------------------------------------------------- | -------------------------- |
|
||||||
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
||||||
| `glm-5` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
|
| `glm-5.1` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
|
||||||
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
|
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
|
||||||
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
||||||
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Self-hosted, multi-user AI agent platform. TypeScript monorepo.
|
|||||||
- **Web**: Next.js 16 + React 19 (`apps/web`)
|
- **Web**: Next.js 16 + React 19 (`apps/web`)
|
||||||
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
|
- **ORM**: Drizzle ORM + PostgreSQL 17 + pgvector (`packages/db`)
|
||||||
- **Auth**: BetterAuth (`packages/auth`)
|
- **Auth**: BetterAuth (`packages/auth`)
|
||||||
- **Agent**: Pi SDK (`packages/agent`, `packages/cli`)
|
- **Agent**: Pi SDK (`packages/agent`, `packages/mosaic`)
|
||||||
- **Queue**: Valkey 8 (`packages/queue`)
|
- **Queue**: Valkey 8 (`packages/queue`)
|
||||||
- **Build**: pnpm workspaces + Turborepo
|
- **Build**: pnpm workspaces + Turborepo
|
||||||
- **CI**: Woodpecker CI
|
- **CI**: Woodpecker CI
|
||||||
|
|||||||
144
README.md
144
README.md
@@ -7,26 +7,39 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
|
|||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the direct URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL …) --yes # Accept all defaults
|
||||||
|
bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs both components:
|
This installs both components:
|
||||||
|
|
||||||
| Component | What | Where |
|
| Component | What | Where |
|
||||||
| -------------------- | ----------------------------------------------------- | -------------------- |
|
| ----------------------- | ---------------------------------------------------------------- | -------------------- |
|
||||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||||
| **@mosaicstack/cli** | TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||||
|
|
||||||
After install, set up your agent identity:
|
After install, the wizard runs automatically or you can invoke it manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic init # Interactive wizard
|
mosaic wizard # Full guided setup (gateway install → verify)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Node.js ≥ 20
|
- Node.js ≥ 20
|
||||||
- npm (for global @mosaicstack/cli install)
|
- npm (for global @mosaicstack/mosaic 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)
|
- 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
|
## Usage
|
||||||
@@ -49,10 +62,32 @@ The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic tui # Interactive TUI connected to the gateway
|
mosaic tui # Interactive TUI connected to the gateway
|
||||||
mosaic login # Authenticate with a gateway instance
|
mosaic gateway login # Authenticate with a gateway instance
|
||||||
mosaic sessions list # List active agent sessions
|
mosaic sessions list # List active agent sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Gateway Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic gateway install # Install and configure the gateway service
|
||||||
|
mosaic gateway verify # Post-install health check
|
||||||
|
mosaic gateway login # Authenticate and store a session token
|
||||||
|
mosaic gateway config rotate-token # Rotate your API token
|
||||||
|
mosaic gateway config recover-token # Recover a token via BetterAuth cookie
|
||||||
|
```
|
||||||
|
|
||||||
|
If you already have a gateway account but no token, use `mosaic gateway config recover-token` to retrieve one without recreating your account.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic config show # Print full config as JSON
|
||||||
|
mosaic config get <key> # Read a specific key
|
||||||
|
mosaic config set <key> <val># Write a key
|
||||||
|
mosaic config edit # Open config in $EDITOR
|
||||||
|
mosaic config path # Print config file path
|
||||||
|
```
|
||||||
|
|
||||||
### Management
|
### Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -65,6 +100,80 @@ mosaic coord init # Initialize a new orchestration mission
|
|||||||
mosaic prdy init # Create a PRD via guided session
|
mosaic prdy init # Create a PRD via guided session
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Sub-package Commands
|
||||||
|
|
||||||
|
Each Mosaic sub-package exposes its API surface through the unified CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User management
|
||||||
|
mosaic auth users list
|
||||||
|
mosaic auth users create
|
||||||
|
mosaic auth sso
|
||||||
|
|
||||||
|
# Agent brain (projects, missions, tasks)
|
||||||
|
mosaic brain projects
|
||||||
|
mosaic brain missions
|
||||||
|
mosaic brain tasks
|
||||||
|
mosaic brain conversations
|
||||||
|
|
||||||
|
# Agent forge pipeline
|
||||||
|
mosaic forge run
|
||||||
|
mosaic forge status
|
||||||
|
mosaic forge resume
|
||||||
|
mosaic forge personas
|
||||||
|
|
||||||
|
# Structured logging
|
||||||
|
mosaic log tail
|
||||||
|
mosaic log search
|
||||||
|
mosaic log export
|
||||||
|
mosaic log level
|
||||||
|
|
||||||
|
# MACP protocol
|
||||||
|
mosaic macp tasks
|
||||||
|
mosaic macp submit
|
||||||
|
mosaic macp gate
|
||||||
|
mosaic macp events
|
||||||
|
|
||||||
|
# Agent memory
|
||||||
|
mosaic memory search
|
||||||
|
mosaic memory stats
|
||||||
|
mosaic memory insights
|
||||||
|
mosaic memory preferences
|
||||||
|
|
||||||
|
# Task queue (Valkey)
|
||||||
|
mosaic queue list
|
||||||
|
mosaic queue stats
|
||||||
|
mosaic queue pause
|
||||||
|
mosaic queue resume
|
||||||
|
mosaic queue jobs
|
||||||
|
mosaic queue drain
|
||||||
|
|
||||||
|
# Object storage
|
||||||
|
mosaic storage status
|
||||||
|
mosaic storage tier
|
||||||
|
mosaic storage export
|
||||||
|
mosaic storage import
|
||||||
|
mosaic storage migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Telemetry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local observability (OTEL / Jaeger)
|
||||||
|
mosaic telemetry local status
|
||||||
|
mosaic telemetry local tail
|
||||||
|
mosaic telemetry local jaeger
|
||||||
|
|
||||||
|
# Remote telemetry (dry-run by default)
|
||||||
|
mosaic telemetry status
|
||||||
|
mosaic telemetry opt-in
|
||||||
|
mosaic telemetry opt-out
|
||||||
|
mosaic telemetry test
|
||||||
|
mosaic telemetry upload # Dry-run unless opted in
|
||||||
|
```
|
||||||
|
|
||||||
|
Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -76,8 +185,8 @@ mosaic prdy init # Create a PRD via guided session
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@git.mosaicstack.dev:mosaic/mosaic-stack.git
|
git clone git@git.mosaicstack.dev:mosaicstack/stack.git
|
||||||
cd mosaic-stack
|
cd stack
|
||||||
|
|
||||||
# Start infrastructure (Postgres, Valkey, Jaeger)
|
# Start infrastructure (Postgres, Valkey, Jaeger)
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -126,13 +235,12 @@ npm packages are published to the Gitea package registry on main merges.
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
mosaic-stack/
|
stack/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
||||||
│ └── web/ Next.js dashboard (React 19, Tailwind)
|
│ └── web/ Next.js dashboard (React 19, Tailwind)
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── cli/ Mosaic CLI — TUI, gateway client, wizard
|
│ ├── mosaic/ Unified CLI — TUI, gateway client, wizard, sub-package commands
|
||||||
│ ├── mosaic/ Framework — wizard, runtime detection, update checker
|
|
||||||
│ ├── types/ Shared TypeScript contracts (Socket.IO typed events)
|
│ ├── types/ Shared TypeScript contracts (Socket.IO typed events)
|
||||||
│ ├── db/ Drizzle ORM schema + migrations (pgvector)
|
│ ├── db/ Drizzle ORM schema + migrations (pgvector)
|
||||||
│ ├── auth/ BetterAuth configuration
|
│ ├── auth/ BetterAuth configuration
|
||||||
@@ -153,7 +261,7 @@ mosaic-stack/
|
|||||||
│ ├── macp/ OpenClaw MACP runtime plugin
|
│ ├── macp/ OpenClaw MACP runtime plugin
|
||||||
│ └── mosaic-framework/ OpenClaw framework injection plugin
|
│ └── mosaic-framework/ OpenClaw framework injection plugin
|
||||||
├── tools/
|
├── tools/
|
||||||
│ └── install.sh Unified installer (framework + npm CLI)
|
│ └── install.sh Unified installer (framework + npm CLI, --yes / --no-auto-launch)
|
||||||
├── scripts/agent/ Agent session lifecycle scripts
|
├── scripts/agent/ Agent session lifecycle scripts
|
||||||
├── docker-compose.yml Dev infrastructure
|
├── docker-compose.yml Dev infrastructure
|
||||||
└── .woodpecker/ CI pipeline configs
|
└── .woodpecker/ CI pipeline configs
|
||||||
@@ -200,7 +308,13 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
|
|||||||
Run the installer again — it handles upgrades automatically:
|
Run the installer again — it handles upgrades automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the direct URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the CLI:
|
Or use the CLI:
|
||||||
@@ -219,6 +333,8 @@ bash tools/install.sh --check # Version check only
|
|||||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
||||||
|
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||||
|
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "apps/gateway"
|
"directory": "apps/gateway"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -72,11 +72,17 @@
|
|||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@nestjs/testing": "^11.1.18",
|
||||||
|
"@swc/core": "^1.15.24",
|
||||||
|
"@swc/helpers": "^0.5.21",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/supertest": "^7.2.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.0.0",
|
"tsx": "^4.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
|
"unplugin-swc": "^1.5.9",
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import type { Auth } from '@mosaicstack/auth';
|
|||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
import type { BootstrapSetupDto, BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
|
import { BootstrapSetupDto } from './bootstrap.dto.js';
|
||||||
|
import type { BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
|
||||||
|
|
||||||
@Controller('api/bootstrap')
|
@Controller('api/bootstrap')
|
||||||
export class BootstrapController {
|
export class BootstrapController {
|
||||||
|
|||||||
190
apps/gateway/src/admin/bootstrap.e2e.spec.ts
Normal file
190
apps/gateway/src/admin/bootstrap.e2e.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* E2E integration test — POST /api/bootstrap/setup
|
||||||
|
*
|
||||||
|
* Regression guard for the `import type { BootstrapSetupDto }` class-erasure
|
||||||
|
* bug (IUV-M01, issue #436).
|
||||||
|
*
|
||||||
|
* When `BootstrapSetupDto` is imported with `import type`, TypeScript erases
|
||||||
|
* the class at compile time. NestJS then sees `Object` as the `@Body()`
|
||||||
|
* metatype, and ValidationPipe with `whitelist:true + forbidNonWhitelisted:true`
|
||||||
|
* treats every property as non-whitelisted, returning:
|
||||||
|
*
|
||||||
|
* 400 { message: ["property email should not exist", "property password should not exist"] }
|
||||||
|
*
|
||||||
|
* The fix is a plain value import (`import { BootstrapSetupDto }`), which
|
||||||
|
* preserves the class reference so Nest can read the class-validator decorators.
|
||||||
|
*
|
||||||
|
* This test MUST fail if `import type` is re-introduced on `BootstrapSetupDto`.
|
||||||
|
* A controller unit test that constructs ValidationPipe manually won't catch
|
||||||
|
* this — only the real DI binding path exercises the metatype lookup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { ValidationPipe, type INestApplication } from '@nestjs/common';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { BootstrapController } from './bootstrap.controller.js';
|
||||||
|
import type { BootstrapResultDto } from './bootstrap.dto.js';
|
||||||
|
|
||||||
|
// ─── Minimal mock dependencies ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We use explicit `@Inject(AUTH)` / `@Inject(DB)` in the controller so we
|
||||||
|
* can provide mock values by token without spinning up the real DB or Auth.
|
||||||
|
*/
|
||||||
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
|
const MOCK_USER_ID = 'mock-user-id-001';
|
||||||
|
|
||||||
|
const mockAuth = {
|
||||||
|
api: {
|
||||||
|
createUser: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
user: {
|
||||||
|
id: MOCK_USER_ID,
|
||||||
|
name: 'Admin',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Override db.select() so the second query (verify user exists) returns a user.
|
||||||
|
// The bootstrap controller calls select().from() twice:
|
||||||
|
// 1. count() to check zero users → returns [{total: 0}]
|
||||||
|
// 2. select().where().limit() → returns [the created user]
|
||||||
|
let selectCallCount = 0;
|
||||||
|
const mockDbWithUser = {
|
||||||
|
select: () => {
|
||||||
|
selectCallCount++;
|
||||||
|
return {
|
||||||
|
from: () => {
|
||||||
|
if (selectCallCount === 1) {
|
||||||
|
// First call: count — zero users
|
||||||
|
return Promise.resolve([{ total: 0 }]);
|
||||||
|
}
|
||||||
|
// Subsequent calls: return a mock user row
|
||||||
|
return {
|
||||||
|
where: () => ({
|
||||||
|
limit: () =>
|
||||||
|
Promise.resolve([
|
||||||
|
{
|
||||||
|
id: MOCK_USER_ID,
|
||||||
|
name: 'Admin',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
role: 'admin',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
update: () => ({
|
||||||
|
set: () => ({
|
||||||
|
where: () => Promise.resolve([]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
insert: () => ({
|
||||||
|
values: () => ({
|
||||||
|
returning: () =>
|
||||||
|
Promise.resolve([
|
||||||
|
{
|
||||||
|
id: 'token-id-001',
|
||||||
|
label: 'Initial setup token',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Test suite ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('POST /api/bootstrap/setup — ValidationPipe DTO binding', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
selectCallCount = 0;
|
||||||
|
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
controllers: [BootstrapController],
|
||||||
|
providers: [
|
||||||
|
{ provide: AUTH, useValue: mockAuth },
|
||||||
|
{ provide: DB, useValue: mockDbWithUser },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
|
||||||
|
// Mirror main.ts configuration exactly — this is what reproduced the 400.
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
// Fastify requires waiting for the adapter to be ready
|
||||||
|
await app.getHttpAdapter().getInstance().ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 201 (not 400) when a valid {name, email, password} body is sent', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/api/bootstrap/setup')
|
||||||
|
.send({ name: 'Admin', email: 'admin@example.com', password: 'password123' })
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
// Before the fix (import type), Nest ValidationPipe returned 400 with
|
||||||
|
// "property email should not exist" / "property password should not exist"
|
||||||
|
// because the DTO class was erased and every field looked non-whitelisted.
|
||||||
|
expect(res.status).not.toBe(400);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = res.body as BootstrapResultDto;
|
||||||
|
expect(body.user).toBeDefined();
|
||||||
|
expect(body.user.email).toBe('admin@example.com');
|
||||||
|
expect(body.token).toBeDefined();
|
||||||
|
expect(body.token.plaintext).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when extra forbidden properties are sent', async () => {
|
||||||
|
// This proves ValidationPipe IS active and working (forbidNonWhitelisted).
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/api/bootstrap/setup')
|
||||||
|
.send({
|
||||||
|
name: 'Admin',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
extraField: 'should-be-rejected',
|
||||||
|
})
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when email is invalid', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/api/bootstrap/setup')
|
||||||
|
.send({ name: 'Admin', email: 'not-an-email', password: 'password123' })
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when password is too short', async () => {
|
||||||
|
const res = await request(app.getHttpServer())
|
||||||
|
.post('/api/bootstrap/setup')
|
||||||
|
.send({ name: 'Admin', email: 'admin@example.com', password: 'short' })
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import swc from 'unplugin-swc';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -5,4 +6,22 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
},
|
},
|
||||||
|
plugins: [
|
||||||
|
swc.vite({
|
||||||
|
jsc: {
|
||||||
|
parser: {
|
||||||
|
syntax: 'typescript',
|
||||||
|
decorators: true,
|
||||||
|
},
|
||||||
|
transform: {
|
||||||
|
decoratorMetadata: true,
|
||||||
|
legacyDecorator: true,
|
||||||
|
},
|
||||||
|
target: 'es2022',
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
type: 'nodenext',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,70 +1,73 @@
|
|||||||
# Mission Manifest — Harness Foundation
|
# Mission Manifest — Install UX v2
|
||||||
|
|
||||||
> Persistent document tracking full mission scope, status, and session history.
|
> Persistent document tracking full mission scope, status, and session history.
|
||||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||||
|
|
||||||
## Mission
|
## Mission
|
||||||
|
|
||||||
**ID:** harness-20260321
|
**ID:** install-ux-v2-20260405
|
||||||
**Statement:** Transform Mosaic Stack from a functional demo into a real multi-provider, task-routing AI harness. Persist all conversations, integrate frontier LLM providers (Anthropic, OpenAI, OpenRouter, Z.ai, Ollama), build granular task-aware agent routing, harden agent sessions, replace cron with BullMQ, and design the channel protocol for future Matrix/remote integration.
|
**Statement:** The install-ux-hardening mission shipped the plumbing (uninstall, masked password, hooks consent, unified flow, headless path), but the first real end-to-end run surfaced a critical regression and a collection of UX failings that make the wizard feel neither quick nor intelligent. This mission closes the bootstrap regression as a hotfix, then rethinks the first-run experience around a provider-first, intent-driven flow with a drill-down main menu and a genuinely fast quick-start.
|
||||||
**Phase:** Complete
|
**Phase:** Execution
|
||||||
**Current Milestone:** All milestones done
|
**Current Milestone:** IUV-M03
|
||||||
**Progress:** 7 / 7 milestones
|
**Progress:** 2 / 3 milestones
|
||||||
**Status:** complete
|
**Status:** active
|
||||||
**Last Updated:** 2026-03-22 UTC
|
**Last Updated:** 2026-04-05 (IUV-M02 complete — CORS/FQDN + skill installer rework)
|
||||||
|
**Parent Mission:** [install-ux-hardening-20260405](./archive/missions/install-ux-hardening-20260405/MISSION-MANIFEST.md) (complete — `mosaic-v0.0.25`)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Real-run testing of `@mosaicstack/mosaic@0.0.25` uncovered:
|
||||||
|
|
||||||
|
1. **Critical:** admin bootstrap fails with HTTP 400 `property email should not exist` — `bootstrap.controller.ts` uses `import type { BootstrapSetupDto }`, erasing the class at runtime. Nest's `@Body()` falls back to plain `Object` metatype, and ValidationPipe with `forbidNonWhitelisted` rejects every property. One-character fix (drop the `type` keyword), but it blocks the happy path of the release that just shipped.
|
||||||
|
2. The wizard reports `✔ Wizard complete` and `✔ Done` _after_ the bootstrap 400 — failure only propagates in headless mode (`wizard.ts:147`).
|
||||||
|
3. The gateway port prompt does not prefill `14242` in the input buffer.
|
||||||
|
4. `"What is Mosaic?"` intro copy does not mention Pi SDK (the actual agent runtime behind Claude/Codex/OpenCode).
|
||||||
|
5. CORS origin prompt is confusing — the user should be able to supply an FQDN/hostname and have the system derive the CORS value.
|
||||||
|
6. Skill / additional feature install section is unusable in practice.
|
||||||
|
7. Quick-start asks far too many questions to be meaningfully "quick".
|
||||||
|
8. No drill-down main menu — everything is a linear interrogation.
|
||||||
|
9. Provider setup happens late and without intelligence. An OpenClaw-style provider-first flow would let the user describe what they want in natural language, have the agent expound on it, and have the agent choose its own name based on that intent.
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
- [x] AC-1: Send messages in TUI → restart TUI → resume conversation → agent has full history and context
|
- [x] AC-1: Admin bootstrap completes successfully end-to-end on a fresh install (DTO value import, no forbidNonWhitelisted regression); covered by an integration or e2e test that exercises the real DTO binding. _(PR #440)_
|
||||||
- [x] AC-2: Route a coding task to Claude Opus 4.6, a simple question to Haiku, a summarization to GLM-5 — all via granular routing rules
|
- [x] AC-2: Wizard fails loudly (non-zero exit, clear error) when the bootstrap stage returns `completed: false`, in both interactive and headless modes. No more silent `✔ Wizard complete` after a 400. _(PR #440)_
|
||||||
- [x] AC-3: Two users exist, User A's memory searches never return User B's data
|
- [x] AC-3: Gateway port prompt prefills `14242` in the input field (user can press Enter to accept). _(PR #440)_
|
||||||
- [x] AC-4: `/model claude-sonnet-4-6` in TUI switches the active model for subsequent messages
|
- [x] AC-4: `"What is Mosaic?"` intro copy mentions Pi SDK as the underlying agent runtime. _(PR #440)_
|
||||||
- [x] AC-5: `/agent coding-agent` in TUI switches to a different agent with different system prompt and tools
|
- [x] AC-5: Release `mosaic-v0.0.26` tagged and published to the Gitea npm registry, unblocking the 0.0.25 happy path. _(tag: mosaic-v0.0.26, registry: 0.0.26 live)_
|
||||||
- [x] AC-6: BullMQ jobs execute on schedule, failures retry with backoff, admin can inspect via `/api/admin/jobs`
|
- [ ] AC-6: CORS origin prompt replaced with FQDN/hostname input; CORS string is derived from that.
|
||||||
- [x] AC-7: Channel protocol document exists with Matrix integration points defined, reviewed, and approved
|
- [ ] AC-7: Skill / additional feature install section is reworked until it is actually usable end-to-end (worker defines the concrete failure modes during diagnosis).
|
||||||
- [x] AC-8: Embeddings run on Ollama local models (no external API dependency for vector operations)
|
- [ ] AC-8: First-run flow has a drill-down main menu with at least `Plugins` (Recommended / Custom), `Providers`, and the other top-level configuration groups. Linear interrogation is gone.
|
||||||
- [x] AC-9: All five providers (Anthropic, OpenAI, OpenRouter, Z.ai, Ollama) connect, list models, and complete chat requests
|
- [ ] AC-9: `Quick Start` path completes with a minimal, curated set of questions (target: under 90 seconds for a returning user; define the exact baseline during design).
|
||||||
- [x] AC-10: Routing transparency — TUI displays which model was selected and the routing reason for each response
|
- [ ] AC-10: Provider setup happens first, driven by a natural-language intake prompt. The agent expounds on the user's intent and chooses its own name based on that intent (OpenClaw-style). Naming is confirmable / overridable.
|
||||||
|
- [ ] AC-11: All milestones ship as merged PRs with green CI and closed issues.
|
||||||
|
|
||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||||
| --- | ------ | ---------------------------------- | ------ | ------ | --------- | ---------- | ---------- |
|
| --- | ------- | ------------------------------------------------------------ | ----------- | ---------------------- | ----- | ---------- | ---------- |
|
||||||
| 1 | ms-166 | Conversation Persistence & Context | done | — | #224–#231 | 2026-03-21 | 2026-03-21 |
|
| 1 | IUV-M01 | Hotfix: bootstrap DTO + wizard failure + port prefill + copy | complete | fix/bootstrap-hotfix | #436 | 2026-04-05 | 2026-04-05 |
|
||||||
| 2 | ms-167 | Security & Isolation | done | — | #232–#239 | 2026-03-21 | 2026-03-21 |
|
| 2 | IUV-M02 | UX polish: CORS/FQDN, skill installer rework | complete | feat/install-ux-polish | #437 | 2026-04-05 | 2026-04-05 |
|
||||||
| 3 | ms-168 | Provider Integration | done | — | #240–#251 | 2026-03-21 | 2026-03-22 |
|
| 3 | IUV-M03 | Provider-first intelligent flow + drill-down main menu | not-started | feat/install-ux-intent | #438 | — | — |
|
||||||
| 4 | ms-169 | Agent Routing Engine | done | — | #252–#264 | 2026-03-22 | 2026-03-22 |
|
|
||||||
| 5 | ms-170 | Agent Session Hardening | done | — | #265–#272 | 2026-03-22 | 2026-03-22 |
|
|
||||||
| 6 | ms-171 | Job Queue Foundation | done | — | #273–#280 | 2026-03-22 | 2026-03-22 |
|
|
||||||
| 7 | ms-172 | Channel Protocol Design | done | — | #281–#288 | 2026-03-22 | 2026-03-22 |
|
|
||||||
|
|
||||||
## Deployment
|
## Subagent Delegation Plan
|
||||||
|
|
||||||
| Target | URL | Method |
|
| Milestone | Recommended Tier | Rationale |
|
||||||
| -------------------- | --------- | -------------------------- |
|
| --------- | ---------------- | --------------------------------------------------------------------- |
|
||||||
| Docker Compose (dev) | localhost | docker compose up |
|
| IUV-M01 | sonnet | Tight bug cluster with known fix sites + small release cycle |
|
||||||
| Production | TBD | Docker Swarm via Portainer |
|
| IUV-M02 | sonnet | UX rework, moderate surface, diagnostic-heavy for the skill installer |
|
||||||
|
| IUV-M03 | opus | Architectural redesign of first-run flow, state machine + LLM intake |
|
||||||
|
|
||||||
## Coordination
|
## Risks
|
||||||
|
|
||||||
- **Primary Agent:** claude-opus-4-6
|
- **Hotfix regression surface** — the `import type` → `import` fix on the DTO class is one character but needs an integration test that binds the real DTO, not just a controller unit test, to prevent the same class-erasure regression from sneaking back in.
|
||||||
- **Sibling Agents:** sonnet (workers), haiku (verification)
|
- **LLM-driven intake latency / offline** — M03's provider-first intent flow assumes an available LLM call to expound on user input and choose a name. Offline installs need a deterministic fallback.
|
||||||
- **Shared Contracts:** docs/PRD-Harness_Foundation.md, docs/TASKS.md
|
- **Menu vs. linear back-compat** — M03 changes the top-level flow shape; existing `tools/install.sh --yes` + env-var headless path must continue to work.
|
||||||
|
- **Scope creep in M03** — "redesign the wizard" can absorb arbitrary work. Keep it bounded with explicit non-goals.
|
||||||
|
|
||||||
## Token Budget
|
## Out of Scope
|
||||||
|
|
||||||
| Metric | Value |
|
- Migrating the wizard to a GUI / web UI (still terminal-first)
|
||||||
| ------ | ------ |
|
- Replacing the Gitea registry or the Woodpecker publish pipeline
|
||||||
| Budget | — |
|
- Multi-tenant / multi-user onboarding (still single-admin bootstrap)
|
||||||
| Used | ~2.5M |
|
- Reworking `mosaic uninstall` (M01 of the parent mission — stable)
|
||||||
| Mode | normal |
|
|
||||||
|
|
||||||
## Session History
|
|
||||||
|
|
||||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
|
||||||
| ------- | --------------- | ---------- | -------- | ------------ | ----------------- |
|
|
||||||
| 1 | claude-opus-4-6 | 2026-03-21 | ~6h | complete | M7-008 — all done |
|
|
||||||
|
|
||||||
## Scratchpad
|
|
||||||
|
|
||||||
Path: `docs/scratchpads/harness-20260321.md`
|
|
||||||
|
|||||||
@@ -1,30 +1,39 @@
|
|||||||
# Tasks — Storage Abstraction Retrofit
|
# Tasks — Install UX v2
|
||||||
|
|
||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
>
|
>
|
||||||
> **Mission:** Decouple gateway from hardcoded Postgres/Valkey backends. Introduce interface-driven middleware so the gateway is backend-agnostic. Default to local tier (SQLite + JSON) for zero-dependency installs.
|
> **Mission:** install-ux-v2-20260405
|
||||||
>
|
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||||
> **`agent` column values:** `codex` | `sonnet` | `haiku` | `glm-5` | `opus` | `—` (auto/default)
|
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||||
|
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `—` (auto)
|
||||||
|
|
||||||
| id | status | agent | description | tokens |
|
## Milestone 1 — Hotfix: bootstrap DTO + wizard failure + port prefill + copy (IUV-M01)
|
||||||
| --------- | ----------- | ------ | ---------------------------------------------------------------- | ------ |
|
|
||||||
| SA-P1-001 | done | sonnet | Define QueueAdapter interface in packages/queue/src/types.ts | 3K |
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
| SA-P1-002 | done | sonnet | Define StorageAdapter interface in packages/storage/src/types.ts | 3K |
|
| --------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | -------------------- | ---------- | -------- | --------------------------------------------------------------------------------------- |
|
||||||
| SA-P1-003 | done | sonnet | Define MemoryAdapter interface in packages/memory/src/types.ts | 3K |
|
| IUV-01-01 | done | Fix `apps/gateway/src/admin/bootstrap.controller.ts:16` — switch `import type { BootstrapSetupDto }` to a value import so Nest's `@Body()` binds the real class | #436 | sonnet | fix/bootstrap-hotfix | — | 3K | PR #440 merged `0ae932ab` |
|
||||||
| SA-P1-004 | done | sonnet | Create adapter factory pattern + config types | 3K |
|
| IUV-01-02 | done | Add integration / e2e test that POSTs `/api/bootstrap/setup` with `{name,email,password}` against a real Nest app instance and asserts 201 — NOT a mocked controller unit test | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-01 | 10K | `apps/gateway/src/admin/bootstrap.e2e.spec.ts` — 4 tests; unplugin-swc added for vitest |
|
||||||
| SA-P2-001 | done | sonnet | Refactor @mosaicstack/queue: wrap ioredis as BullMQ adapter | 3K |
|
| IUV-01-03 | done | `packages/mosaic/src/wizard.ts:147` — propagate `!bootstrapResult.completed` as a wizard failure in **interactive** mode too (not only headless); non-zero exit + no `✔ Wizard complete` line | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-02 | 5K | removed `&& headlessRun` guard |
|
||||||
| SA-P2-002 | done | sonnet | Create @mosaicstack/storage: wrap Drizzle as Postgres adapter | 6K |
|
| IUV-01-04 | done | Gateway port prompt prefills `14242` in the input buffer — investigate why `promptPort`'s `defaultValue` isn't reaching the user-visible input | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-03 | 5K | added `initialValue` through prompter interface → clack |
|
||||||
| SA-P2-003 | done | sonnet | Refactor @mosaicstack/memory: extract pgvector adapter | 4K |
|
| IUV-01-05 | done | `"What is Mosaic?"` intro copy updated to mention Pi SDK as the underlying agent runtime (alongside Claude Code / Codex / OpenCode) | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-04 | 2K | `packages/mosaic/src/stages/welcome.ts` |
|
||||||
| SA-P2-004 | done | sonnet | Update gateway modules to use factories + DI tokens | 5K |
|
| IUV-01-06 | done | Tests + code review + PR merge + tag `mosaic-v0.0.26` + Gitea release + npm registry republish | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-05 | 10K | PRs #440/#441/#442 merged; tag `mosaic-v0.0.26`; registry latest=0.0.26 ✓ |
|
||||||
| SA-P2-005 | done | opus | Verify Phase 2: all tests pass, typecheck clean | — |
|
|
||||||
| SA-P3-001 | done | sonnet | Implement local queue adapter: JSON file persistence | 5K |
|
## Milestone 2 — UX polish: CORS/FQDN, skill installer rework (IUV-M02)
|
||||||
| SA-P3-002 | done | sonnet | Implement SQLite storage adapter with better-sqlite3 | 8K |
|
|
||||||
| SA-P3-003 | done | sonnet | Implement keyword memory adapter — no vector dependency | 4K |
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
| SA-P3-004 | done | opus | Verify Phase 3: 42 new tests, 347 total passing | — |
|
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------- | ---------- | -------- | ---------------------------------------------------------------------- |
|
||||||
| SA-P4-001 | done | sonnet | MosaicConfig schema + loader with tier auto-detection | 6K |
|
| IUV-02-01 | done | Replace CORS origin prompt with FQDN / hostname input; derive the CORS value internally; default to `localhost` with clear help text | #437 | sonnet | feat/install-ux-polish | — | 10K | `deriveCorsOrigin()` pure fn; MOSAIC_HOSTNAME headless var; PR #444 |
|
||||||
| SA-P4-002 | done | sonnet | CLI: mosaic gateway init — interactive wizard | 4K |
|
| IUV-02-02 | done | Diagnose and document the concrete failure modes of the current skill / additional feature install section end-to-end | #437 | sonnet | feat/install-ux-polish | IUV-02-01 | 8K | selection→install gap, silent catch{}, no whitelist concept |
|
||||||
| SA-P4-003 | done | sonnet | CLI: mosaic gateway start/stop/status lifecycle | 5K |
|
| IUV-02-03 | done | Rework the skill installer so it is usable end-to-end (selection, install, verify, failure reporting) | #437 | sonnet | feat/install-ux-polish | IUV-02-02 | 20K | MOSAIC_INSTALL_SKILLS env var whitelist; SyncSkillsResult typed return |
|
||||||
| SA-P4-004 | done | opus | Verify Phase 4: 381 tests passing, 40/40 tasks clean | — |
|
| IUV-02-04 | done | Tests + code review + PR merge | #437 | sonnet | feat/install-ux-polish | IUV-02-03 | 10K | 18 new tests (13 CORS + 5 skills); PR #444 merged `172bacb3` |
|
||||||
| SA-P5-001 | not-started | codex | Migration tooling: mosaic storage export/import | — |
|
|
||||||
| SA-P5-002 | not-started | codex | Docker Compose profiles: local vs team | — |
|
## Milestone 3 — Provider-first intelligent flow + drill-down main menu (IUV-M03)
|
||||||
| SA-P5-003 | not-started | codex | Final verification + docs: README, architecture diagram | — |
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----- | ---------------------- | ---------- | -------- | ------------------------------------------------------------- |
|
||||||
|
| IUV-03-01 | not-started | Design doc: new first-run state machine — main menu (Plugins / Providers / …), Quick Start vs Custom paths, provider-first flow, intent intake + naming loop | #438 | opus | feat/install-ux-intent | — | 15K | scratchpad + explicit non-goals |
|
||||||
|
| IUV-03-02 | not-started | Implement drill-down main menu (Plugins: Recommended / Custom, Providers, …) as the top-level entry point of `mosaic wizard` | #438 | opus | feat/install-ux-intent | IUV-03-01 | 25K | |
|
||||||
|
| IUV-03-03 | not-started | Quick Start path: curated minimum question set — define the exact baseline, delete everything else from the fast path | #438 | opus | feat/install-ux-intent | IUV-03-02 | 15K | |
|
||||||
|
| IUV-03-04 | not-started | Provider-first natural-language intake: user describes intent → agent expounds → agent proposes a name (confirmable / overridable) — OpenClaw-style | #438 | opus | feat/install-ux-intent | IUV-03-03 | 25K | offline fallback required (deterministic default name + path) |
|
||||||
|
| IUV-03-05 | not-started | Preserve backward-compat: headless path (`MOSAIC_ASSUME_YES=1` + env vars) still works end-to-end; `tools/install.sh --yes` unchanged | #438 | opus | feat/install-ux-intent | IUV-03-04 | 10K | |
|
||||||
|
| IUV-03-06 | not-started | Tests + code review + PR merge + `mosaic-v0.0.27` release | #438 | opus | feat/install-ux-intent | IUV-03-05 | 15K | |
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Mission Manifest — CLI Unification & E2E First-Run
|
||||||
|
|
||||||
|
> Persistent document tracking full mission scope, status, and session history.
|
||||||
|
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
**ID:** cli-unification-20260404
|
||||||
|
**Statement:** Transform the Mosaic CLI from a partially-duplicated, manually-assembled experience into a single cohesive entry point that installs, configures, and controls the entire Mosaic system. Every Mosaic package gets first-class CLI surface. The first-run experience works end-to-end with no manual stitching. Gateway token recovery is possible without the web UI. Opt-in telemetry uses the published telemetry clients.
|
||||||
|
**Phase:** Complete
|
||||||
|
**Current Milestone:** —
|
||||||
|
**Progress:** 8 / 8 milestones
|
||||||
|
**Status:** completed
|
||||||
|
**Last Updated:** 2026-04-05
|
||||||
|
**Release:** [`mosaic-v0.0.24`](https://git.mosaicstack.dev/mosaicstack/mosaic-stack/releases/tag/mosaic-v0.0.24) (`@mosaicstack/mosaic@0.0.24`, alpha — stays in 0.0.x until GA)
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [x] AC-1: Fresh machine `bash <(curl …install.sh)` → single command lands on a working authenticated gateway with a usable admin token; no secondary manual wizards required
|
||||||
|
- [x] AC-2: `mosaic --help` lists every sub-package as a top-level command and is alphabetized for readability
|
||||||
|
- [x] AC-3: `mosaic auth`, `mosaic brain`, `mosaic forge`, `mosaic log`, `mosaic macp`, `mosaic memory`, `mosaic queue`, `mosaic storage`, `mosaic telemetry` each expose at least one working subcommand that exercises the underlying package
|
||||||
|
- [x] AC-4: Gateway admin token can be rotated or recovered from the CLI alone — operator is never stranded because the web UI is inaccessible
|
||||||
|
- [x] AC-5: `mosaic telemetry` uses the published `@mosaicstack/telemetry-client-js` (from the Gitea npm registry); local OTEL stays for wide-event logging / post-mortems; remote upload is opt-in and disabled by default
|
||||||
|
- [x] AC-6: Install → wizard → gateway install → TUI verification flow is a single cohesive path with clear state transitions and no dead ends
|
||||||
|
- [x] AC-7: `@mosaicstack/mosaic` is the sole `mosaic` binary owner; `@mosaicstack/cli` is gone from the repo and all docs
|
||||||
|
- [x] AC-8: All milestones ship as merged PRs with green CI, closed issues, and updated release notes
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||||
|
| --- | ------ | ------------------------------------------------------------------------ | ------ | ----------------------------------- | --------------------------------- | ---------- | ---------- |
|
||||||
|
| 1 | cu-m01 | Kill legacy @mosaicstack/cli package | done | chore/remove-cli-package-duplicate | #398 | 2026-04-04 | 2026-04-04 |
|
||||||
|
| 2 | cu-m02 | Archive stale mission state + scaffold new mission | done | docs/mission-cli-unification | #399 | 2026-04-04 | 2026-04-04 |
|
||||||
|
| 3 | cu-m03 | Fix gateway bootstrap token recovery (server + CLI paths) | done | feat/gateway-token-recovery | #411, #414 | 2026-04-05 | 2026-04-05 |
|
||||||
|
| 4 | cu-m04 | Alphabetize + group `mosaic --help` output | done | feat/help-sort + feat/mosaic-config | #402, #408 | 2026-04-05 | 2026-04-05 |
|
||||||
|
| 5 | cu-m05 | Sub-package CLI surface (auth/brain/forge/log/macp/memory/queue/storage) | done | feat/mosaic-\*-cli (x9) | #403–#407, #410, #412, #413, #415 | 2026-04-05 | 2026-04-05 |
|
||||||
|
| 6 | cu-m06 | `mosaic telemetry` — local OTEL + opt-in remote upload | done | feat/mosaic-telemetry | #417 | 2026-04-05 | 2026-04-05 |
|
||||||
|
| 7 | cu-m07 | Unified first-run UX (install.sh → wizard → gateway → TUI) | done | feat/mosaic-first-run-ux | #418 | 2026-04-05 | 2026-04-05 |
|
||||||
|
| 8 | cu-m08 | Docs refresh + release tag | done | docs/cli-unification-release-v0.1.0 | #419 | 2026-04-05 | 2026-04-05 |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
| Target | URL | Method |
|
||||||
|
| -------------------- | --------- | ----------------------------------------------- |
|
||||||
|
| Local tier (default) | localhost | `mosaic gateway install` — pglite + local queue |
|
||||||
|
| Team tier | any host | `mosaic gateway install` — PG + Valkey |
|
||||||
|
| Docker Compose (dev) | localhost | `docker compose up` for PG/Valkey/OTEL/Jaeger |
|
||||||
|
|
||||||
|
## Coordination
|
||||||
|
|
||||||
|
- **Primary Agent:** claude-opus-4-6[1m]
|
||||||
|
- **Sibling Agents:** sonnet (standard implementation), haiku (status/explore/verify), codex (coding-heavy tasks)
|
||||||
|
- **Shared Contracts:** `docs/PRD.md` (existing v0.1.0 PRD — still the long-term target), this manifest, `docs/TASKS.md`, `docs/scratchpads/cli-unification-20260404.md`
|
||||||
|
|
||||||
|
## Token Budget
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
| ------ | ------ |
|
||||||
|
| Budget | TBD |
|
||||||
|
| Used | ~80K |
|
||||||
|
| Mode | normal |
|
||||||
|
|
||||||
|
## Session History
|
||||||
|
|
||||||
|
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||||
|
| ------- | --------------- | ---------- | -------- | ---------------- | ------------------------------------------------------------ |
|
||||||
|
| 1 | claude-opus-4-6 | 2026-04-04 | ~4h | context-budget | cu-m01 + cu-m02 merged (#398, #399); open questions resolved |
|
||||||
|
| 2 | claude-opus-4-6 | 2026-04-05 | ~6h | mission-complete | cu-m03..cu-m08 all merged; mosaic-v0.1.0 released |
|
||||||
|
|
||||||
|
## Scratchpad
|
||||||
|
|
||||||
|
Path: `docs/scratchpads/cli-unification-20260404.md`
|
||||||
90
docs/archive/missions/cli-unification-20260404/TASKS.md
Normal file
90
docs/archive/missions/cli-unification-20260404/TASKS.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Tasks — CLI Unification & E2E First-Run
|
||||||
|
|
||||||
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
>
|
||||||
|
> **Mission:** cli-unification-20260404
|
||||||
|
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||||
|
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||||
|
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `glm-5` | `—` (auto)
|
||||||
|
|
||||||
|
## Milestone 1 — Kill legacy @mosaicstack/cli (done)
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| -------- | ------ | ----------------------------------------------------------------- | ----- | ----- | ---------------------------------- | ---------- | -------- | --------------------------- |
|
||||||
|
| CU-01-01 | done | Delete packages/cli directory; update workspace + docs references | #398 | opus | chore/remove-cli-package-duplicate | — | 5K | Merged c39433c3. 6685 LOC−. |
|
||||||
|
|
||||||
|
## Milestone 2 — Archive stale mission + scaffold new mission (done)
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| -------- | ------ | ------------------------------------------------------------------ | ----- | ----- | ---------------------------- | ---------- | -------- | --------------------------------- |
|
||||||
|
| CU-02-01 | done | Move stale MISSION-MANIFEST / TASKS / PRD-Harness to docs/archive/ | #399 | opus | docs/mission-cli-unification | CU-01-01 | 3K | Harness + storage missions done. |
|
||||||
|
| CU-02-02 | done | Scaffold new MISSION-MANIFEST.md, TASKS.md, scratchpad | #399 | opus | docs/mission-cli-unification | CU-02-01 | 5K | This file + manifest + scratchpad |
|
||||||
|
| CU-02-03 | done | PR review, merge, branch cleanup | #399 | opus | docs/mission-cli-unification | CU-02-02 | 2K | Merged as 6f15a84c |
|
||||||
|
|
||||||
|
## Milestone 3 — Gateway bootstrap token recovery
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| -------- | ------ | ---------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----------------------------- |
|
||||||
|
| CU-03-01 | done | Implementation plan for BetterAuth-cookie recovery flow (decision locked 2026-04-04) | — | opus | — | CU-02-03 | 4K | Design locked; plan-only task |
|
||||||
|
| CU-03-02 | done | Server: add recovery/rotate endpoint on apps/gateway/src/admin (gated by design from CU-03-01) | — | sonnet | — | CU-03-01 | 12K | |
|
||||||
|
| CU-03-03 | done | CLI: `mosaic gateway login` — interactive BetterAuth sign-in, persist session | — | sonnet | — | CU-03-02 | 10K | |
|
||||||
|
| CU-03-04 | done | CLI: `mosaic gateway config rotate-token` — mint new admin token via authenticated API | — | sonnet | — | CU-03-03 | 8K | |
|
||||||
|
| CU-03-05 | done | CLI: `mosaic gateway config recover-token` — execute the recovery flow from CU-03-01 | — | sonnet | — | CU-03-03 | 10K | |
|
||||||
|
| CU-03-06 | done | Install UX: fix the "user exists, no token" dead-end in runInstall bootstrapFirstUser path | — | sonnet | — | CU-03-05 | 8K | |
|
||||||
|
| CU-03-07 | done | Tests: integration tests for each recovery path (happy + error) | — | sonnet | — | CU-03-06 | 10K | |
|
||||||
|
| CU-03-08 | done | Code review + remediation | — | haiku | — | CU-03-07 | 4K | |
|
||||||
|
|
||||||
|
## Milestone 4 — `mosaic --help` alphabetize + grouping
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ------------------------------- |
|
||||||
|
| CU-04-01 | done | Enable `configureHelp({ sortSubcommands: true })` on root program and each subgroup | — | sonnet | — | CU-02-03 | 3K | |
|
||||||
|
| CU-04-02 | done | Group commands into sections (Runtime, Gateway, Framework, Platform) in help output | — | sonnet | — | CU-04-01 | 5K | |
|
||||||
|
| CU-04-03 | done | Verify help snapshots render readably; update any docs with stale output | — | haiku | — | CU-04-02 | 3K | |
|
||||||
|
| CU-04-04 | done | Top-level `mosaic config` command — `show`, `get <key>`, `set <key> <val>`, `edit`, `path` — wraps packages/mosaic/src/config/config-service.ts (framework/agent config; distinct from `mosaic gateway config`) | — | sonnet | — | CU-02-03 | 10K | New scope (decision 2026-04-04) |
|
||||||
|
| CU-04-05 | done | Tests + code review for CU-04-04 | — | haiku | — | CU-04-04 | 4K | |
|
||||||
|
|
||||||
|
## Milestone 5 — Sub-package CLI surface
|
||||||
|
|
||||||
|
> Pattern: each sub-package exports `register<Name>Command(program: Command)` co-located with the library code (proven by `@mosaicstack/quality-rails`). Wire into `packages/mosaic/src/cli.ts`.
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| -------- | ------ | --------------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ------------------- |
|
||||||
|
| CU-05-01 | done | `mosaic forge` — subcommands: `run`, `status`, `resume`, `personas list` | — | sonnet | — | CU-02-03 | 18K | User priority |
|
||||||
|
| CU-05-02 | done | `mosaic storage` — subcommands: `status`, `tier show`, `tier switch`, `export`, `import`, `migrate` | — | sonnet | — | CU-02-03 | 15K | |
|
||||||
|
| CU-05-03 | done | `mosaic queue` — subcommands: `list`, `stats`, `pause/resume`, `jobs tail`, `drain` | — | sonnet | — | CU-02-03 | 12K | |
|
||||||
|
| CU-05-04 | done | `mosaic memory` — subcommands: `search`, `stats`, `insights list`, `preferences list` | — | sonnet | — | CU-02-03 | 12K | |
|
||||||
|
| CU-05-05 | done | `mosaic brain` — subcommands: `projects list/create`, `missions list`, `tasks list`, `conversations list` | — | sonnet | — | CU-02-03 | 15K | |
|
||||||
|
| CU-05-06 | done | `mosaic auth` — subcommands: `users list/create/delete`, `sso list`, `sso test`, `sessions list` | — | sonnet | — | CU-03-03 | 15K | needs gateway login |
|
||||||
|
| CU-05-07 | done | `mosaic log` — subcommands: `tail`, `search`, `export`, `level <level>` | — | sonnet | — | CU-02-03 | 10K | |
|
||||||
|
| CU-05-08 | done | `mosaic macp` — subcommands: `tasks list`, `submit`, `gate`, `events tail` | — | sonnet | — | CU-02-03 | 12K | |
|
||||||
|
| CU-05-09 | done | Wire all eight `register<Name>Command` calls into packages/mosaic/src/cli.ts | — | haiku | — | CU-05-01…8 | 3K | |
|
||||||
|
| CU-05-10 | done | Integration test: `mosaic <cmd> --help` exits 0 for every new command | — | haiku | — | CU-05-09 | 5K | |
|
||||||
|
|
||||||
|
## Milestone 6 — `mosaic telemetry`
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| -------- | ------ | ------------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ---------------------------------------------- |
|
||||||
|
| CU-06-01 | done | Add `@mosaicstack/telemetry-client-js` as dependency of `@mosaicstack/mosaic` from Gitea registry | — | sonnet | — | CU-02-03 | 3K | |
|
||||||
|
| CU-06-02 | done | `mosaic telemetry local` — status, tail, Jaeger link (wraps existing apps/gateway/src/tracing.ts) | — | sonnet | — | CU-06-01 | 8K | |
|
||||||
|
| CU-06-03 | done | `mosaic telemetry` — status, opt-in, opt-out, test, upload (uses telemetry-client-js) | — | sonnet | — | CU-06-01 | 12K | Dry-run mode when server endpoint not yet live |
|
||||||
|
| CU-06-04 | done | Persistent consent state in mosaic config; disabled by default | — | sonnet | — | CU-06-03 | 5K | |
|
||||||
|
| CU-06-05 | done | Tests + code review | — | haiku | — | CU-06-04 | 5K | |
|
||||||
|
|
||||||
|
## Milestone 7 — Unified first-run UX
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| -------- | ------ | ---------------------------------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----- |
|
||||||
|
| CU-07-01 | done | tools/install.sh: after npm install, hand off to `mosaic wizard` then `mosaic gateway install` | — | sonnet | — | CU-03-06 | 10K | |
|
||||||
|
| CU-07-02 | done | `mosaic wizard` and `mosaic gateway install` coordination: shared state, no duplicate prompts | — | sonnet | — | CU-07-01 | 12K | |
|
||||||
|
| CU-07-03 | done | Post-install verification step: "gateway healthy, tui connects, admin token on file" | — | sonnet | — | CU-07-02 | 8K | |
|
||||||
|
| CU-07-04 | done | End-to-end test on a clean container from scratch | — | haiku | — | CU-07-03 | 8K | |
|
||||||
|
|
||||||
|
## Milestone 8 — Docs + release
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| -------- | ------ | ---------------------------------------------------------------------- | ----- | ------ | ------ | ---------- | -------- | ----- |
|
||||||
|
| CU-08-01 | done | Update README.md with new command tree, install flow, and feature list | — | sonnet | — | CU-07-04 | 8K | |
|
||||||
|
| CU-08-02 | done | Update docs/guides/user-guide.md with all new sub-package commands | — | sonnet | — | CU-08-01 | 10K | |
|
||||||
|
| CU-08-03 | done | Version bump `@mosaicstack/mosaic`, publish to Gitea registry | — | opus | — | CU-08-02 | 3K | |
|
||||||
|
| CU-08-04 | done | Release notes, tag `v0.1.0-rc.N`, publish release on Gitea | — | opus | — | CU-08-03 | 3K | |
|
||||||
70
docs/archive/missions/harness-20260321/MISSION-MANIFEST.md
Normal file
70
docs/archive/missions/harness-20260321/MISSION-MANIFEST.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Mission Manifest — Harness Foundation
|
||||||
|
|
||||||
|
> Persistent document tracking full mission scope, status, and session history.
|
||||||
|
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
**ID:** harness-20260321
|
||||||
|
**Statement:** Transform Mosaic Stack from a functional demo into a real multi-provider, task-routing AI harness. Persist all conversations, integrate frontier LLM providers (Anthropic, OpenAI, OpenRouter, Z.ai, Ollama), build granular task-aware agent routing, harden agent sessions, replace cron with BullMQ, and design the channel protocol for future Matrix/remote integration.
|
||||||
|
**Phase:** Complete
|
||||||
|
**Current Milestone:** All milestones done
|
||||||
|
**Progress:** 7 / 7 milestones
|
||||||
|
**Status:** complete
|
||||||
|
**Last Updated:** 2026-03-22 UTC
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [x] AC-1: Send messages in TUI → restart TUI → resume conversation → agent has full history and context
|
||||||
|
- [x] AC-2: Route a coding task to Claude Opus 4.6, a simple question to Haiku, a summarization to GLM-5 — all via granular routing rules
|
||||||
|
- [x] AC-3: Two users exist, User A's memory searches never return User B's data
|
||||||
|
- [x] AC-4: `/model claude-sonnet-4-6` in TUI switches the active model for subsequent messages
|
||||||
|
- [x] AC-5: `/agent coding-agent` in TUI switches to a different agent with different system prompt and tools
|
||||||
|
- [x] AC-6: BullMQ jobs execute on schedule, failures retry with backoff, admin can inspect via `/api/admin/jobs`
|
||||||
|
- [x] AC-7: Channel protocol document exists with Matrix integration points defined, reviewed, and approved
|
||||||
|
- [x] AC-8: Embeddings run on Ollama local models (no external API dependency for vector operations)
|
||||||
|
- [x] AC-9: All five providers (Anthropic, OpenAI, OpenRouter, Z.ai, Ollama) connect, list models, and complete chat requests
|
||||||
|
- [x] AC-10: Routing transparency — TUI displays which model was selected and the routing reason for each response
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||||
|
| --- | ------ | ---------------------------------- | ------ | ------ | --------- | ---------- | ---------- |
|
||||||
|
| 1 | ms-166 | Conversation Persistence & Context | done | — | #224–#231 | 2026-03-21 | 2026-03-21 |
|
||||||
|
| 2 | ms-167 | Security & Isolation | done | — | #232–#239 | 2026-03-21 | 2026-03-21 |
|
||||||
|
| 3 | ms-168 | Provider Integration | done | — | #240–#251 | 2026-03-21 | 2026-03-22 |
|
||||||
|
| 4 | ms-169 | Agent Routing Engine | done | — | #252–#264 | 2026-03-22 | 2026-03-22 |
|
||||||
|
| 5 | ms-170 | Agent Session Hardening | done | — | #265–#272 | 2026-03-22 | 2026-03-22 |
|
||||||
|
| 6 | ms-171 | Job Queue Foundation | done | — | #273–#280 | 2026-03-22 | 2026-03-22 |
|
||||||
|
| 7 | ms-172 | Channel Protocol Design | done | — | #281–#288 | 2026-03-22 | 2026-03-22 |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
| Target | URL | Method |
|
||||||
|
| -------------------- | --------- | -------------------------- |
|
||||||
|
| Docker Compose (dev) | localhost | docker compose up |
|
||||||
|
| Production | TBD | Docker Swarm via Portainer |
|
||||||
|
|
||||||
|
## Coordination
|
||||||
|
|
||||||
|
- **Primary Agent:** claude-opus-4-6
|
||||||
|
- **Sibling Agents:** sonnet (workers), haiku (verification)
|
||||||
|
- **Shared Contracts:** docs/PRD-Harness_Foundation.md, docs/TASKS.md
|
||||||
|
|
||||||
|
## Token Budget
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
| ------ | ------ |
|
||||||
|
| Budget | — |
|
||||||
|
| Used | ~2.5M |
|
||||||
|
| Mode | normal |
|
||||||
|
|
||||||
|
## Session History
|
||||||
|
|
||||||
|
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||||
|
| ------- | --------------- | ---------- | -------- | ------------ | ----------------- |
|
||||||
|
| 1 | claude-opus-4-6 | 2026-03-21 | ~6h | complete | M7-008 — all done |
|
||||||
|
|
||||||
|
## Scratchpad
|
||||||
|
|
||||||
|
Path: `docs/scratchpads/harness-20260321.md`
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Mission Manifest — Install UX Hardening
|
||||||
|
|
||||||
|
> Persistent document tracking full mission scope, status, and session history.
|
||||||
|
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
**ID:** install-ux-hardening-20260405
|
||||||
|
**Statement:** Close the remaining gaps in the Mosaic Stack first-run and teardown experience uncovered by the post-`cli-unification` audit. A user MUST be able to cleanly uninstall the stack; the wizard MUST make security-sensitive surfaces visible (hooks, password entry); and CI/headless installs MUST NOT hang on interactive prompts. The longer-term goal is a single cohesive first-run flow that collapses `mosaic wizard` and `mosaic gateway install` into one state-bridged experience.
|
||||||
|
**Phase:** Complete
|
||||||
|
**Current Milestone:** —
|
||||||
|
**Progress:** 3 / 3 milestones
|
||||||
|
**Status:** complete
|
||||||
|
**Last Updated:** 2026-04-05 (mission complete)
|
||||||
|
**Parent Mission:** [cli-unification-20260404](./archive/missions/cli-unification-20260404/MISSION-MANIFEST.md) (complete)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Post-merge audit of `cli-unification-20260404` (AC-1, AC-6) validated that the first-run wizard covers first user, password, admin tokens, gateway instance config, skills, and SOUL.md/USER.md init. The audit surfaced six gaps, grouped into three tracks of independent value.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [x] AC-1: `mosaic uninstall` (top-level) cleanly reverses every mutation made by `tools/install.sh` — framework data, npm CLI, nested stack deps, runtime asset injections in `~/.claude/`, npmrc scope mapping, PATH edits. Dry-run supported. `--keep-data` preserves memory + user files + gateway DB. (PR #429)
|
||||||
|
- [x] AC-2: `curl … | bash -s -- --uninstall` works without requiring a functioning CLI. (PR #429)
|
||||||
|
- [x] AC-3: Password entry in `bootstrapFirstUser` is masked (no plaintext echo); confirm prompt added. (PR #431)
|
||||||
|
- [x] AC-4: Wizard has an explicit hooks stage that previews which hooks will be installed, asks for confirmation, and records the user's choice. `mosaic config hooks list|enable|disable` surface exists. (PR #431 — consent; PR #433 — finalize-stage gating now honors `state.hooks.accepted === false` end-to-end)
|
||||||
|
- [x] AC-5: `runConfigWizard` and `bootstrapFirstUser` accept a headless path (env vars + `--yes`) so `tools/install.sh --yes` + `MOSAIC_ASSUME_YES=1` completes end-to-end in CI without TTY. (PR #431)
|
||||||
|
- [x] AC-6: `mosaic wizard` and `mosaic gateway install` are collapsed into a single cohesive entry point with shared state; gateway install is now terminal stages 11 & 12 of `runWizard`, session-file bridge removed, `mosaic gateway install` preserved as a thin standalone wrapper. (PR #433)
|
||||||
|
- [x] AC-7: All milestones shipped as merged PRs with green CI and closed issues. (PRs #429, #431, #433)
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||||
|
| --- | ------- | --------------------------------------------------------- | ------ | ----------------------- | ----- | ---------- | ---------- |
|
||||||
|
| 1 | IUH-M01 | `mosaic uninstall` — top-level teardown + shell wrapper | done | feat/mosaic-uninstall | #425 | 2026-04-05 | 2026-04-05 |
|
||||||
|
| 2 | IUH-M02 | Wizard remediation — hooks visibility, pwd mask, headless | done | feat/wizard-remediation | #426 | 2026-04-05 | 2026-04-05 |
|
||||||
|
| 3 | IUH-M03 | Unified first-run wizard (collapse wizard + gateway) | done | feat/unified-first-run | #427 | 2026-04-05 | 2026-04-05 |
|
||||||
|
|
||||||
|
## Subagent Delegation Plan
|
||||||
|
|
||||||
|
| Milestone | Recommended Tier | Rationale |
|
||||||
|
| --------- | ---------------- | ---------------------------------------------------------------------- |
|
||||||
|
| IUH-M01 | sonnet | Standard feature work — new command surface mirroring existing install |
|
||||||
|
| IUH-M02 | sonnet | Small surgical fixes across 3-4 files |
|
||||||
|
| IUH-M03 | opus | Architectural refactor; state machine design decisions |
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Reversal completeness** — runtime asset linking creates `.mosaic-bak-*` backups; uninstall must honor them vs. when to delete. Ambiguity without an install manifest.
|
||||||
|
- **npm global nested deps** — `npm uninstall -g @mosaicstack/mosaic` removes nested `@mosaicstack/*`, but ownership conflicts with explicitly installed peer packages (`@mosaicstack/gateway`, `@mosaicstack/memory`) need test coverage.
|
||||||
|
- **Headless bootstrap** — admin password via env var is a credential on disk; needs clear documentation that `MOSAIC_ADMIN_PASSWORD` is intended for CI-only and should be rotated post-install.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- `mosaicstack.dev/install.sh` vanity URL (blocked on marketing site work)
|
||||||
|
- Uninstall for the `@mosaicstack/gateway` database contents — delegated to `mosaic gateway uninstall` semantics already in place
|
||||||
|
- Signature/checksum verification of install scripts
|
||||||
41
docs/archive/missions/install-ux-hardening-20260405/TASKS.md
Normal file
41
docs/archive/missions/install-ux-hardening-20260405/TASKS.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Tasks — Install UX Hardening
|
||||||
|
|
||||||
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
>
|
||||||
|
> **Mission:** install-ux-hardening-20260405
|
||||||
|
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||||
|
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||||
|
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `—` (auto)
|
||||||
|
|
||||||
|
## Milestone 1 — `mosaic uninstall` (IUH-M01)
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------- | ----- | ------ | --------------------- | ---------- | -------- | ------------------------------------------------------ |
|
||||||
|
| IUH-01-01 | done | Design install manifest schema (`~/.config/mosaic/.install-manifest.json`) — what install writes on first success | #425 | sonnet | feat/mosaic-uninstall | — | 8K | v1 schema in `install-manifest.ts` |
|
||||||
|
| IUH-01-02 | done | `mosaic uninstall` TS command: `--framework`, `--cli`, `--gateway`, `--all`, `--keep-data`, `--yes`, `--dry-run` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-01 | 25K | `uninstall.ts` |
|
||||||
|
| IUH-01-03 | done | Reverse runtime asset linking in `~/.claude/` — restore `.mosaic-bak-*` if present, remove managed copies otherwise | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 12K | file list hardcoded from mosaic-link-runtime-assets |
|
||||||
|
| IUH-01-04 | done | Reverse npmrc scope mapping and PATH edits made by `tools/install.sh` | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 8K | npmrc reversed; no PATH edits found in v0.0.24 install |
|
||||||
|
| IUH-01-05 | done | Shell fallback: `tools/install.sh --uninstall` path for users without a working CLI | #425 | sonnet | feat/mosaic-uninstall | IUH-01-02 | 10K | |
|
||||||
|
| IUH-01-06 | done | Vitest coverage: dry-run output, `--all`, `--keep-data`, partial state, missing manifest | #425 | sonnet | feat/mosaic-uninstall | IUH-01-05 | 15K | 14 new tests, 170 total |
|
||||||
|
| IUH-01-07 | done | Code review (independent) + remediation | #425 | sonnet | feat/mosaic-uninstall | IUH-01-06 | 5K | |
|
||||||
|
| IUH-01-08 | done | PR open, CI green, review, merge to `main`, close issue | #425 | sonnet | feat/mosaic-uninstall | IUH-01-07 | 3K | PR #429, merge 25cada77 |
|
||||||
|
|
||||||
|
## Milestone 2 — Wizard Remediation (IUH-M02)
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| --------- | ------ | -------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------------- | ---------- | -------- | ----------------------------------------------- |
|
||||||
|
| IUH-02-01 | done | Password masking: replace plaintext `rl.question` in `bootstrapFirstUser` with masked TTY read + confirmation | #426 | sonnet | feat/wizard-remediation | IUH-01-08 | 8K | `prompter/masked-prompt.ts` |
|
||||||
|
| IUH-02-02 | done | Hooks preview stage in wizard: show `framework/runtime/claude/hooks-config.json` entries + confirm prompt | #426 | sonnet | feat/wizard-remediation | IUH-02-01 | 12K | `stages/hooks-preview.ts`; finalize gating TODO |
|
||||||
|
| IUH-02-03 | done | `mosaic config hooks list\|enable\|disable` subcommands | #426 | sonnet | feat/wizard-remediation | IUH-02-02 | 15K | `commands/config.ts` |
|
||||||
|
| IUH-02-04 | done | Headless path: env-var driven `runConfigWizard` + `bootstrapFirstUser` (`MOSAIC_ASSUME_YES`, `MOSAIC_ADMIN_*`) | #426 | sonnet | feat/wizard-remediation | IUH-02-03 | 12K | |
|
||||||
|
| IUH-02-05 | done | Tests + code review + PR merge | #426 | sonnet | feat/wizard-remediation | IUH-02-04 | 10K | PR #431, merge cd8b1f66 |
|
||||||
|
|
||||||
|
## Milestone 3 — Unified First-Run Wizard (IUH-M03)
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| --------- | ------ | ----------------------------------------------------------------------------------------------------------- | ----- | ----- | ---------------------- | ---------- | -------- | ---------------------------------- |
|
||||||
|
| IUH-03-01 | done | Design doc: unified state machine; decide whether `mosaic gateway install` becomes an internal wizard stage | #427 | opus | feat/unified-first-run | IUH-02-05 | 10K | scratchpad Session 5 |
|
||||||
|
| IUH-03-02 | done | Refactor `runWizard` to invoke gateway install as a stage; drop the 10-minute session-file bridge | #427 | opus | feat/unified-first-run | IUH-03-01 | 25K | stages 11 & 12; bridge removed |
|
||||||
|
| IUH-03-03 | done | Preserve backward-compat: `mosaic gateway install` still works as a standalone entry point | #427 | opus | feat/unified-first-run | IUH-03-02 | 10K | thin wrapper over stages |
|
||||||
|
| IUH-03-04 | done | Tests + code review + PR merge | #427 | opus | feat/unified-first-run | IUH-03-03 | 12K | PR #433, merge 732f8a49; +15 tests |
|
||||||
|
| IUH-03-05 | done | Bonus: honor `state.hooks.accepted` in finalize stage (closes M02 follow-up) | #427 | opus | feat/unified-first-run | IUH-03-04 | 5K | MOSAIC_SKIP_CLAUDE_HOOKS env flag |
|
||||||
30
docs/archive/missions/storage-abstraction/TASKS.md
Normal file
30
docs/archive/missions/storage-abstraction/TASKS.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Tasks — Storage Abstraction Retrofit
|
||||||
|
|
||||||
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
>
|
||||||
|
> **Mission:** Decouple gateway from hardcoded Postgres/Valkey backends. Introduce interface-driven middleware so the gateway is backend-agnostic. Default to local tier (SQLite + JSON) for zero-dependency installs.
|
||||||
|
>
|
||||||
|
> **`agent` column values:** `codex` | `sonnet` | `haiku` | `glm-5` | `opus` | `—` (auto/default)
|
||||||
|
|
||||||
|
| id | status | agent | description | tokens |
|
||||||
|
| --------- | ----------- | ------ | ---------------------------------------------------------------- | ------ |
|
||||||
|
| SA-P1-001 | done | sonnet | Define QueueAdapter interface in packages/queue/src/types.ts | 3K |
|
||||||
|
| SA-P1-002 | done | sonnet | Define StorageAdapter interface in packages/storage/src/types.ts | 3K |
|
||||||
|
| SA-P1-003 | done | sonnet | Define MemoryAdapter interface in packages/memory/src/types.ts | 3K |
|
||||||
|
| SA-P1-004 | done | sonnet | Create adapter factory pattern + config types | 3K |
|
||||||
|
| SA-P2-001 | done | sonnet | Refactor @mosaicstack/queue: wrap ioredis as BullMQ adapter | 3K |
|
||||||
|
| SA-P2-002 | done | sonnet | Create @mosaicstack/storage: wrap Drizzle as Postgres adapter | 6K |
|
||||||
|
| SA-P2-003 | done | sonnet | Refactor @mosaicstack/memory: extract pgvector adapter | 4K |
|
||||||
|
| SA-P2-004 | done | sonnet | Update gateway modules to use factories + DI tokens | 5K |
|
||||||
|
| SA-P2-005 | done | opus | Verify Phase 2: all tests pass, typecheck clean | — |
|
||||||
|
| SA-P3-001 | done | sonnet | Implement local queue adapter: JSON file persistence | 5K |
|
||||||
|
| SA-P3-002 | done | sonnet | Implement SQLite storage adapter with better-sqlite3 | 8K |
|
||||||
|
| SA-P3-003 | done | sonnet | Implement keyword memory adapter — no vector dependency | 4K |
|
||||||
|
| SA-P3-004 | done | opus | Verify Phase 3: 42 new tests, 347 total passing | — |
|
||||||
|
| SA-P4-001 | done | sonnet | MosaicConfig schema + loader with tier auto-detection | 6K |
|
||||||
|
| SA-P4-002 | done | sonnet | CLI: mosaic gateway init — interactive wizard | 4K |
|
||||||
|
| SA-P4-003 | done | sonnet | CLI: mosaic gateway start/stop/status lifecycle | 5K |
|
||||||
|
| SA-P4-004 | done | opus | Verify Phase 4: 381 tests passing, 40/40 tasks clean | — |
|
||||||
|
| SA-P5-001 | not-started | codex | Migration tooling: mosaic storage export/import | — |
|
||||||
|
| SA-P5-002 | not-started | codex | Docker Compose profiles: local vs team | — |
|
||||||
|
| SA-P5-003 | not-started | codex | Final verification + docs: README, architecture diagram | — |
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
4. [Tasks](#tasks)
|
4. [Tasks](#tasks)
|
||||||
5. [Settings](#settings)
|
5. [Settings](#settings)
|
||||||
6. [CLI Usage](#cli-usage)
|
6. [CLI Usage](#cli-usage)
|
||||||
|
7. [Sub-package Commands](#sub-package-commands)
|
||||||
|
8. [Telemetry](#telemetry)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -160,12 +162,24 @@ The `mosaic` CLI provides a terminal interface to the same gateway API.
|
|||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
The CLI ships as part of the `@mosaicstack/cli` package:
|
Install via the Mosaic installer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# From the monorepo root
|
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||||
pnpm --filter @mosaicstack/cli build
|
```
|
||||||
node packages/cli/dist/cli.js --help
|
|
||||||
|
Or use the direct URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
|
||||||
|
non-interactive use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
--yes # Accept all defaults
|
||||||
|
--no-auto-launch # Skip auto-launch of wizard after install
|
||||||
```
|
```
|
||||||
|
|
||||||
Or if installed globally:
|
Or if installed globally:
|
||||||
@@ -174,7 +188,60 @@ Or if installed globally:
|
|||||||
mosaic --help
|
mosaic --help
|
||||||
```
|
```
|
||||||
|
|
||||||
### Signing In
|
### First-Run Wizard
|
||||||
|
|
||||||
|
After install the wizard launches automatically. You can re-run it at any time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
The wizard guides you through:
|
||||||
|
|
||||||
|
1. Gateway discovery or installation (`mosaic gateway install`)
|
||||||
|
2. Authentication (`mosaic gateway login`)
|
||||||
|
3. Post-install health check (`mosaic gateway verify`)
|
||||||
|
|
||||||
|
### Gateway Login and Token Recovery
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Authenticate with a gateway and save a session token
|
||||||
|
mosaic gateway login
|
||||||
|
|
||||||
|
# Verify the gateway is reachable and responding
|
||||||
|
mosaic gateway verify
|
||||||
|
|
||||||
|
# Rotate your current API token
|
||||||
|
mosaic gateway config rotate-token
|
||||||
|
|
||||||
|
# Recover a token via BetterAuth cookie (for accounts with no token)
|
||||||
|
mosaic gateway config recover-token
|
||||||
|
```
|
||||||
|
|
||||||
|
If you have an existing gateway account but lost your token (common after a
|
||||||
|
reinstall), use `mosaic gateway config recover-token` to retrieve a new one
|
||||||
|
without recreating your account.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Print full config as JSON
|
||||||
|
mosaic config show
|
||||||
|
|
||||||
|
# Read a specific key
|
||||||
|
mosaic config get gateway.url
|
||||||
|
|
||||||
|
# Write a key
|
||||||
|
mosaic config set gateway.url http://localhost:14242
|
||||||
|
|
||||||
|
# Open config in $EDITOR
|
||||||
|
mosaic config edit
|
||||||
|
|
||||||
|
# Print config file path
|
||||||
|
mosaic config path
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signing In (Legacy)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic login --gateway http://localhost:14242 --email you@example.com
|
mosaic login --gateway http://localhost:14242 --email you@example.com
|
||||||
@@ -236,3 +303,267 @@ mosaic prdy
|
|||||||
# Quality rails scaffolder
|
# Quality rails scaffolder
|
||||||
mosaic quality-rails
|
mosaic quality-rails
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sub-package Commands
|
||||||
|
|
||||||
|
Each Mosaic sub-package exposes its full API surface through the `mosaic` CLI.
|
||||||
|
All sub-package commands accept `--help` for usage details.
|
||||||
|
|
||||||
|
### `mosaic auth` — User & Authentication Management
|
||||||
|
|
||||||
|
Manage gateway users, SSO providers, and active sessions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all users
|
||||||
|
mosaic auth users list
|
||||||
|
|
||||||
|
# Create a new user
|
||||||
|
mosaic auth users create --email alice@example.com --name "Alice"
|
||||||
|
|
||||||
|
# Delete a user
|
||||||
|
mosaic auth users delete <userId>
|
||||||
|
|
||||||
|
# List configured SSO providers
|
||||||
|
mosaic auth sso
|
||||||
|
|
||||||
|
# List active sessions
|
||||||
|
mosaic auth sessions list
|
||||||
|
|
||||||
|
# Revoke a session
|
||||||
|
mosaic auth sessions revoke <sessionId>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `mosaic brain` — Projects, Missions, Tasks, Conversations
|
||||||
|
|
||||||
|
Browse and manage the brain data layer (PostgreSQL-backed project/mission/task
|
||||||
|
store).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all projects
|
||||||
|
mosaic brain projects
|
||||||
|
|
||||||
|
# List missions for a project
|
||||||
|
mosaic brain missions --project <projectId>
|
||||||
|
|
||||||
|
# List tasks
|
||||||
|
mosaic brain tasks --status in-progress
|
||||||
|
|
||||||
|
# Browse conversations
|
||||||
|
mosaic brain conversations
|
||||||
|
mosaic brain conversations --project <projectId>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `mosaic config` — CLI Configuration
|
||||||
|
|
||||||
|
Read and write the `mosaic` CLI configuration file.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show full config
|
||||||
|
mosaic config show
|
||||||
|
|
||||||
|
# Get a value
|
||||||
|
mosaic config get gateway.url
|
||||||
|
|
||||||
|
# Set a value
|
||||||
|
mosaic config set theme dark
|
||||||
|
|
||||||
|
# Open in editor
|
||||||
|
mosaic config edit
|
||||||
|
|
||||||
|
# Print file path
|
||||||
|
mosaic config path
|
||||||
|
```
|
||||||
|
|
||||||
|
### `mosaic forge` — AI Pipeline Management
|
||||||
|
|
||||||
|
Interact with the Forge multi-stage AI delivery pipeline (intake → board review
|
||||||
|
→ planning → coding → review → deploy).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start a new forge run for a brief
|
||||||
|
mosaic forge run --brief "Add dark mode toggle to settings"
|
||||||
|
|
||||||
|
# Check status of a running pipeline
|
||||||
|
mosaic forge status
|
||||||
|
mosaic forge status --run <runId>
|
||||||
|
|
||||||
|
# Resume a paused or interrupted run
|
||||||
|
mosaic forge resume --run <runId>
|
||||||
|
|
||||||
|
# List available personas (board review evaluators)
|
||||||
|
mosaic forge personas
|
||||||
|
```
|
||||||
|
|
||||||
|
### `mosaic gateway` — Gateway Lifecycle
|
||||||
|
|
||||||
|
Install, authenticate with, and verify the Mosaic gateway service.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install gateway (guided)
|
||||||
|
mosaic gateway install
|
||||||
|
|
||||||
|
# Verify gateway health post-install
|
||||||
|
mosaic gateway verify
|
||||||
|
|
||||||
|
# Log in and save token
|
||||||
|
mosaic gateway login
|
||||||
|
|
||||||
|
# Rotate API token
|
||||||
|
mosaic gateway config rotate-token
|
||||||
|
|
||||||
|
# Recover token via BetterAuth cookie (lost-token recovery)
|
||||||
|
mosaic gateway config recover-token
|
||||||
|
```
|
||||||
|
|
||||||
|
### `mosaic log` — Structured Log Access
|
||||||
|
|
||||||
|
Query and stream structured logs from the gateway.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stream live logs
|
||||||
|
mosaic log tail
|
||||||
|
mosaic log tail --level warn
|
||||||
|
|
||||||
|
# Search logs
|
||||||
|
mosaic log search "database connection"
|
||||||
|
mosaic log search --since 1h "error"
|
||||||
|
|
||||||
|
# Export logs to file
|
||||||
|
mosaic log export --output logs.json
|
||||||
|
mosaic log export --since 24h --level error --output errors.json
|
||||||
|
|
||||||
|
# Get/set log level
|
||||||
|
mosaic log level
|
||||||
|
mosaic log level debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### `mosaic macp` — MACP Protocol
|
||||||
|
|
||||||
|
Interact with the MACP credential resolution, gate runner, and event bus.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List MACP tasks
|
||||||
|
mosaic macp tasks
|
||||||
|
mosaic macp tasks --status pending
|
||||||
|
|
||||||
|
# Submit a new MACP task
|
||||||
|
mosaic macp submit --type credential-resolve --payload '{"key":"OPENAI_API_KEY"}'
|
||||||
|
|
||||||
|
# Run a gate check
|
||||||
|
mosaic macp gate --gate quality-check
|
||||||
|
|
||||||
|
# Stream MACP events
|
||||||
|
mosaic macp events
|
||||||
|
mosaic macp events --filter credential
|
||||||
|
```
|
||||||
|
|
||||||
|
### `mosaic memory` — Agent Memory
|
||||||
|
|
||||||
|
Query and inspect the agent memory layer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Semantic search over memory
|
||||||
|
mosaic memory search "previous decisions about auth"
|
||||||
|
|
||||||
|
# Show memory statistics
|
||||||
|
mosaic memory stats
|
||||||
|
|
||||||
|
# Generate memory insights report
|
||||||
|
mosaic memory insights
|
||||||
|
|
||||||
|
# View stored preferences
|
||||||
|
mosaic memory preferences
|
||||||
|
mosaic memory preferences --set editor=neovim
|
||||||
|
```
|
||||||
|
|
||||||
|
### `mosaic queue` — Task Queue (Valkey)
|
||||||
|
|
||||||
|
Manage the Valkey-backed task queue.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all queues
|
||||||
|
mosaic queue list
|
||||||
|
|
||||||
|
# Show queue statistics
|
||||||
|
mosaic queue stats
|
||||||
|
mosaic queue stats --queue agent-tasks
|
||||||
|
|
||||||
|
# Pause a queue
|
||||||
|
mosaic queue pause agent-tasks
|
||||||
|
|
||||||
|
# Resume a paused queue
|
||||||
|
mosaic queue resume agent-tasks
|
||||||
|
|
||||||
|
# List jobs in a queue
|
||||||
|
mosaic queue jobs agent-tasks
|
||||||
|
mosaic queue jobs agent-tasks --status failed
|
||||||
|
|
||||||
|
# Drain (empty) a queue
|
||||||
|
mosaic queue drain agent-tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
### `mosaic storage` — Object Storage
|
||||||
|
|
||||||
|
Manage object storage tiers and data migrations.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show storage status and usage
|
||||||
|
mosaic storage status
|
||||||
|
|
||||||
|
# List available storage tiers
|
||||||
|
mosaic storage tier
|
||||||
|
|
||||||
|
# Export data from storage
|
||||||
|
mosaic storage export --bucket agent-artifacts --output ./artifacts.tar.gz
|
||||||
|
|
||||||
|
# Import data into storage
|
||||||
|
mosaic storage import --bucket agent-artifacts --input ./artifacts.tar.gz
|
||||||
|
|
||||||
|
# Migrate data between tiers
|
||||||
|
mosaic storage migrate --from hot --to cold --older-than 30d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Telemetry
|
||||||
|
|
||||||
|
Mosaic includes an OpenTelemetry-based telemetry system. Local telemetry
|
||||||
|
(traces, metrics sent to Jaeger) is always available. Remote telemetry upload
|
||||||
|
requires explicit opt-in.
|
||||||
|
|
||||||
|
### Local Telemetry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show local OTEL collector / Jaeger status
|
||||||
|
mosaic telemetry local status
|
||||||
|
|
||||||
|
# Tail live OTEL spans
|
||||||
|
mosaic telemetry local tail
|
||||||
|
|
||||||
|
# Open Jaeger UI URL
|
||||||
|
mosaic telemetry local jaeger
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remote Telemetry
|
||||||
|
|
||||||
|
Remote upload is a no-op (dry-run) until you opt in. Your consent state is
|
||||||
|
persisted in the config file.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show current consent state
|
||||||
|
mosaic telemetry status
|
||||||
|
|
||||||
|
# Opt in to remote telemetry
|
||||||
|
mosaic telemetry opt-in
|
||||||
|
|
||||||
|
# Opt out (data stays local)
|
||||||
|
mosaic telemetry opt-out
|
||||||
|
|
||||||
|
# Test telemetry pipeline without uploading
|
||||||
|
mosaic telemetry test
|
||||||
|
|
||||||
|
# Upload telemetry (requires opt-in; dry-run otherwise)
|
||||||
|
mosaic telemetry upload
|
||||||
|
```
|
||||||
|
|||||||
193
docs/plans/gateway-token-recovery.md
Normal file
193
docs/plans/gateway-token-recovery.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# Gateway Admin Token Recovery — Implementation Plan
|
||||||
|
|
||||||
|
**Mission:** `cli-unification-20260404`
|
||||||
|
**Task:** `CU-03-01` (planning only — no runtime code changes)
|
||||||
|
**Status:** Design locked (Session 1) — BetterAuth cookie-based recovery
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem Statement
|
||||||
|
|
||||||
|
The gateway installer strands operators when the admin user exists but the admin
|
||||||
|
API token is missing. Concrete trigger:
|
||||||
|
|
||||||
|
- `~/.config/mosaic/gateway/meta.json` was deleted / regenerated.
|
||||||
|
- The installer was re-run after a previous successful bootstrap.
|
||||||
|
|
||||||
|
Flow today (`packages/mosaic/src/commands/gateway/install.ts:375-400`):
|
||||||
|
|
||||||
|
1. `bootstrapFirstUser` hits `GET /api/bootstrap/status`.
|
||||||
|
2. Server returns `needsSetup: false` because `users` count > 0.
|
||||||
|
3. Installer logs `Admin user already exists — skipping setup. (No admin token on file — sign in via the web UI to manage tokens.)` and returns.
|
||||||
|
4. The operator now has:
|
||||||
|
- No token in `meta.json`.
|
||||||
|
- No CLI path to mint a new one (`mosaic gateway <anything>` that needs the token fails).
|
||||||
|
- `POST /api/bootstrap/setup` locked out — it only runs when `users` count is zero (`apps/gateway/src/admin/bootstrap.controller.ts:34-37`).
|
||||||
|
- `POST /api/admin/tokens` gated by `AdminGuard` — requires either a bearer token (which they don't have) or a BetterAuth session (which they don't have in the CLI).
|
||||||
|
|
||||||
|
Dead end. The web UI is the only escape hatch today, and for headless installs even that may be inaccessible.
|
||||||
|
|
||||||
|
## 2. Design Summary
|
||||||
|
|
||||||
|
The BetterAuth session cookie is the authority. The operator runs
|
||||||
|
`mosaic gateway login` to sign in with email/password, which persists a session
|
||||||
|
cookie via `saveSession` (reusing `packages/mosaic/src/auth.ts`). With a valid
|
||||||
|
session, `mosaic gateway config recover-token` (stranded-operator entry point)
|
||||||
|
and `mosaic gateway config rotate-token` call the existing authenticated admin
|
||||||
|
endpoint `POST /api/admin/tokens` using the cookie, then persist the returned
|
||||||
|
plaintext to `meta.json` via `writeMeta`. **No new server endpoints are
|
||||||
|
required** — `AdminGuard` already accepts BetterAuth session cookies via its
|
||||||
|
`validateSession` path (`apps/gateway/src/admin/admin.guard.ts:90-120`).
|
||||||
|
|
||||||
|
## 3. Surface Contract
|
||||||
|
|
||||||
|
### 3.1 Server — no changes required
|
||||||
|
|
||||||
|
| Endpoint | Status | Notes |
|
||||||
|
| ------------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `POST /api/admin/tokens` | **Reuse as-is** | `admin-tokens.controller.ts:46-72`. Returns `{ id, label, scope, expiresAt, lastUsedAt, createdAt, plaintext }`. |
|
||||||
|
| `GET /api/admin/tokens` | **Reuse** | Useful for `mosaic gateway config tokens list` follow-on (out of scope for CU-03-01, but trivial once auth path exists). |
|
||||||
|
| `DELETE /api/admin/tokens/:id` | **Reuse** | Used by rotate flow for optional old-token revocation. |
|
||||||
|
| `POST /api/bootstrap/setup` | **Unchanged** | Remains first-user-only; not part of recovery. |
|
||||||
|
|
||||||
|
`AdminGuard.validateSession` takes BetterAuth cookies from `request.raw.headers`
|
||||||
|
via `fromNodeHeaders` and calls `auth.api.getSession({ headers })`. It also
|
||||||
|
enforces `role === 'admin'`. This is exactly the path the CLI will hit with
|
||||||
|
`Cookie: better-auth.session_token=...`.
|
||||||
|
|
||||||
|
**Confirmed feasible** during CU-03-01 investigation.
|
||||||
|
|
||||||
|
### 3.2 `mosaic gateway login`
|
||||||
|
|
||||||
|
Thin wrapper over the existing top-level `mosaic login`
|
||||||
|
(`packages/mosaic/src/cli.ts:42-76`) with gateway-specific defaults pulled from
|
||||||
|
`readMeta()`.
|
||||||
|
|
||||||
|
| Aspect | Behavior |
|
||||||
|
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Default gateway URL | `http://${meta.host}:${meta.port}` from `readMeta()`, fallback `http://localhost:14242`. |
|
||||||
|
| Flow | Prompt email + password -> `signIn()` -> `saveSession()`. |
|
||||||
|
| Persistence | `~/.mosaic/session.json` via existing `saveSession` (7-day expiry). |
|
||||||
|
| Decision | **Thin wrapper**, not alias. Rationale: defaults differ (reads `meta.json`), and discoverability under `mosaic gateway --help`. |
|
||||||
|
| Implementation | Share the sign-in logic by extracting a small `runLogin(gatewayUrl, email?, password?)` helper; both commands call it. |
|
||||||
|
|
||||||
|
### 3.3 `mosaic gateway config rotate-token`
|
||||||
|
|
||||||
|
| Aspect | Behavior |
|
||||||
|
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Precondition | Valid session (via `loadSession` + `validateSession`). On failure, print: "Not signed in — run `mosaic gateway login`" and exit non-zero. |
|
||||||
|
| Request | `POST ${gatewayUrl}/api/admin/tokens` with header `Cookie: <session>`, body `{ label: "CLI token (rotated YYYY-MM-DD)" }`. |
|
||||||
|
| On success | Read meta via `readMeta()`, set `meta.adminToken = plaintext`, `writeMeta(meta)`. Print the token banner (reuse `printAdminTokenBanner` shape). |
|
||||||
|
| Old token | **Optional `--revoke-old`** flag. When set and a previous `meta.adminToken` existed, call `DELETE /api/admin/tokens/:id` after rotation. Requires listing first to find the id; punt to CU-03-02 decision. Document as nice-to-have. |
|
||||||
|
| Exit codes | `0` success; `1` network error; `2` auth error; `3` server rejection. |
|
||||||
|
|
||||||
|
### 3.4 `mosaic gateway config recover-token`
|
||||||
|
|
||||||
|
Superset of `rotate-token` with an inline login nudge — the "stranded operator"
|
||||||
|
entry point.
|
||||||
|
|
||||||
|
| Step | Action |
|
||||||
|
| ---- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | `readMeta()` — derive gateway URL. If meta is missing entirely, fall back to `--gateway` flag or default. |
|
||||||
|
| 2 | `loadSession(gatewayUrl)` then `validateSession`. If either fails, prompt inline: email + password -> `signIn` -> `saveSession`. |
|
||||||
|
| 3 | `POST /api/admin/tokens` with cookie, label `"Recovered via CLI YYYY-MM-DDTHH:mm"`. |
|
||||||
|
| 4 | Persist plaintext to `meta.json` via `writeMeta`. |
|
||||||
|
| 5 | Print the token banner and next-steps hints (e.g. `mosaic gateway status`). |
|
||||||
|
| 6 | Exit `0`. |
|
||||||
|
|
||||||
|
Key property: this command is **runnable with nothing but email+password in hand**.
|
||||||
|
It assumes the gateway is up but assumes no prior CLI session state.
|
||||||
|
|
||||||
|
### 3.5 File touch list (for CU-03-02..05 execution)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| `packages/mosaic/src/commands/gateway.ts` | Register `login`, `config recover-token`, `config rotate-token` subcommands under `gw`. |
|
||||||
|
| `packages/mosaic/src/commands/gateway/config.ts` | Add `runRecoverToken`, `runRotateToken` handlers; export from module. |
|
||||||
|
| `packages/mosaic/src/commands/gateway/login.ts` (new) | Thin wrapper calling shared `runLogin` helper with meta-derived default URL. |
|
||||||
|
| `packages/mosaic/src/auth.ts` | No change expected. Possibly export a `requireSession(gatewayUrl)` helper (reuse pattern). |
|
||||||
|
| `packages/mosaic/src/commands/gateway/install.ts` | `bootstrapFirstUser` branch: "user exists, no token" -> offer recovery (see Section 4). |
|
||||||
|
|
||||||
|
## 4. Installer Fix (CU-03-06 preview)
|
||||||
|
|
||||||
|
Current stranding point is `install.ts:388-395`. The fix:
|
||||||
|
|
||||||
|
```
|
||||||
|
if (!status.needsSetup) {
|
||||||
|
if (meta.adminToken) {
|
||||||
|
// unchanged — happy path
|
||||||
|
} else {
|
||||||
|
// NEW: prompt "Admin exists but no token on file. Recover now? [Y/n]"
|
||||||
|
// If yes -> call runRecoverToken(gatewayUrl) inline (interactive):
|
||||||
|
// - prompt email + password
|
||||||
|
// - signIn -> saveSession
|
||||||
|
// - POST /api/admin/tokens
|
||||||
|
// - writeMeta(meta) with returned plaintext
|
||||||
|
// - print banner
|
||||||
|
// If no -> print the current stranded message but include:
|
||||||
|
// "Run `mosaic gateway config recover-token` when ready."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Shape notes (actual code lands in CU-03-06):
|
||||||
|
|
||||||
|
- Extract the recovery body so it can be called **both** from the standalone
|
||||||
|
command and from `bootstrapFirstUser` without duplicating prompts.
|
||||||
|
- Reuse the same `rl` readline interface already open in `bootstrapFirstUser`
|
||||||
|
for the inline prompts.
|
||||||
|
- Preserve non-interactive behavior: if `process.stdin.isTTY` is false, skip the
|
||||||
|
prompt and emit the "run recover-token" hint only.
|
||||||
|
|
||||||
|
## 5. Test Strategy (CU-03-07 scope)
|
||||||
|
|
||||||
|
### 5.1 Happy paths
|
||||||
|
|
||||||
|
| Command | Scenario | Expected |
|
||||||
|
| ------------------------------------- | ------------------------------------------------ | -------------------------------------------------------- |
|
||||||
|
| `mosaic gateway login` | Valid creds | `session.json` written, 7-day expiry, exit 0 |
|
||||||
|
| `mosaic gateway config rotate-token` | Valid session, server reachable | `meta.json` updated, banner printed, new token usable |
|
||||||
|
| `mosaic gateway config recover-token` | No session, valid creds, server reachable | Prompts for creds, writes session + meta, exit 0 |
|
||||||
|
| Installer inline recovery | Re-run after `meta.json` wipe, operator says yes | Meta restored, banner printed, no manual CLI step needed |
|
||||||
|
|
||||||
|
### 5.2 Error paths (must all produce actionable messages and non-zero exit)
|
||||||
|
|
||||||
|
| Failure | Expected handling |
|
||||||
|
| --------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
|
| Invalid email/password | BetterAuth 401 surfaced as "Sign-in failed: <server message>", exit 2 |
|
||||||
|
| Expired stored session | Recover command silently re-prompts; rotate command exits 2 with "run login" hint |
|
||||||
|
| Gateway down / connection refused | "Could not reach gateway at <url>" exit 1 |
|
||||||
|
| Server rejects token creation | Print status + body excerpt, exit 3 |
|
||||||
|
| Meta file missing (recover) | Fall back to `--gateway` flag or default; warn that meta will be created |
|
||||||
|
| Non-admin user | `AdminGuard` 403 surfaced as "User is not an admin", exit 2 |
|
||||||
|
|
||||||
|
### 5.3 Integration test (recommended)
|
||||||
|
|
||||||
|
Spin up gateway in test harness, create admin user via `/api/bootstrap/setup`,
|
||||||
|
wipe `meta.json`, invoke `mosaic gateway config recover-token` programmatically,
|
||||||
|
assert new `meta.adminToken` works against `GET /api/admin/tokens`.
|
||||||
|
|
||||||
|
## 6. Risks & Open Questions
|
||||||
|
|
||||||
|
| # | Item | Severity | Mitigation |
|
||||||
|
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | `AdminGuard.validateSession` calls `getSession` with `fromNodeHeaders(request.raw.headers)`. CLI sends `Cookie:` header only. Confirm BetterAuth reads from `Cookie`, not `Set-Cookie`. | Low | Confirmed — `mosaic login` + `mosaic tui` already use this flow successfully (`cli.ts:137-181`). |
|
||||||
|
| 2 | Session cookie local expiry (7d) vs BetterAuth server-side expiry may drift. | Low | `validateSession` hits `get-session`; handle 401 by re-prompting. |
|
||||||
|
| 3 | Label collision / unbounded token growth if operators run `recover-token` repeatedly. | Low | Include ISO timestamp in label. Optional `--revoke-old` in CU-03-02. Add `tokens list/prune` later. |
|
||||||
|
| 4 | `mosaic login` exists at top level and `mosaic gateway login` is a wrapper — risk of confusion. | Low | Document that `gateway login` is the preferred entry for gateway operators; top-level stays for compatibility. |
|
||||||
|
| 5 | `meta.json` write is not atomic. Crash between token creation and `writeMeta` leaves an orphan token server-side with no plaintext on disk. | Medium | Accept for now — re-running `recover-token` mints a fresh token. Document as known limitation. |
|
||||||
|
| 6 | Non-TTY installer runs (CI, headless provisioners) cannot prompt for creds interactively. | Medium | Installer inline recovery must skip prompt when `!process.stdin.isTTY`; emit the recover-token hint. |
|
||||||
|
| 7 | If `BETTER_AUTH_SECRET` rotates between login and recover, the session cookie is invalid — user must re-login. Acceptable but surface a clear error. | Low | Error handler maps 401 on recover -> "Session invalid; re-run `mosaic gateway login`". |
|
||||||
|
| 8 | No MFA today. When MFA lands, BetterAuth sign-in will return a challenge, not a cookie — recovery UX will need a second prompt step. | Future | Out of scope for this mission. Flag for future CLI work. |
|
||||||
|
|
||||||
|
## 7. Downstream Task Hooks
|
||||||
|
|
||||||
|
| Task | Scope |
|
||||||
|
| -------- | -------------------------------------------------------------------------- |
|
||||||
|
| CU-03-02 | Implement `mosaic gateway login` wrapper + shared `runLogin` extraction. |
|
||||||
|
| CU-03-03 | Implement `mosaic gateway config rotate-token`. |
|
||||||
|
| CU-03-04 | Implement `mosaic gateway config recover-token`. |
|
||||||
|
| CU-03-05 | Wire commands into `gateway.ts` registration, update `--help` copy. |
|
||||||
|
| CU-03-06 | Installer inline recovery hook in `bootstrapFirstUser`. |
|
||||||
|
| CU-03-07 | Tests per Section 5. |
|
||||||
|
| CU-03-08 | Docs: update gateway install README + operator runbook with recovery flow. |
|
||||||
250
docs/scratchpads/cli-unification-20260404.md
Normal file
250
docs/scratchpads/cli-unification-20260404.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# Mission Scratchpad — CLI Unification & E2E First-Run
|
||||||
|
|
||||||
|
> Append-only log. NEVER delete entries. NEVER overwrite sections.
|
||||||
|
> This is the orchestrator's working memory across sessions.
|
||||||
|
|
||||||
|
**Mission ID:** cli-unification-20260404
|
||||||
|
**Started:** 2026-04-04
|
||||||
|
**Related PRDs:** `docs/PRD.md` (v0.1.0 long-term target)
|
||||||
|
|
||||||
|
## Original Mission Prompt
|
||||||
|
|
||||||
|
Original user framing (2026-04-04):
|
||||||
|
|
||||||
|
> We are off the reservation right now. Working on getting the system to work via cli first, then working on the webUI. The missions are likely all wrong. The PRDs might have valid info.
|
||||||
|
>
|
||||||
|
> E2E install to functional, with Mosaic Forge working. `mosaic gateway` config is broken — no token is created. Unable to configure. Installation doesn't really configure, it just installs and launches the gateway. Multiple `mosaic` commands are missing that should be included. Unified installer experience is not ready. UX is bad.
|
||||||
|
>
|
||||||
|
> The various mosaic packages will need to be available within the mosaic cli: `mosaic auth`, `mosaic brain`, `mosaic forge`, `mosaic log`, `mosaic macp`, `mosaic memory`, `mosaic queue`, `mosaic storage`.
|
||||||
|
>
|
||||||
|
> The list of commands in `mosaic --help` also need to be alphabetized for readability.
|
||||||
|
>
|
||||||
|
> `mosaic telemetry` should also exist. Local OTEL for wide-event logging / post-mortems. Remote upload opt-in via `@mosaicstack/telemetry-client-js` (https://git.mosaicstack.dev/mosaicstack/telemetry-client-js) — the telemetry server will be part of the main mosaicstack.dev website. Python counterpart at https://git.mosaicstack.dev/mosaicstack/telemetry-client-py.
|
||||||
|
|
||||||
|
## Planning Decisions
|
||||||
|
|
||||||
|
### 2026-04-04 — State discovery + prep PR
|
||||||
|
|
||||||
|
**Critical finding:** Two CLI packages both owned `bin.mosaic` — `@mosaicstack/mosaic` (0.0.21) and `@mosaicstack/cli` (0.0.17). Their `src/cli.ts` files were near-verbatim duplicates (424 vs 422 lines) and their `src/commands/` directories overlapped, with some files silently diverging (notably `gateway/install.ts`, the version responsible for the broken install UX). Whichever package was linked last won the `mosaic` symlink.
|
||||||
|
|
||||||
|
**Decision:** `@mosaicstack/cli` dies. `@mosaicstack/mosaic` is the single CLI + TUI package. This was confirmed with user ("The @mosaicstack/cli package is no longer a package. Its features were moved to @mosaicstack/mosaic instead."). Prep PR #398 executed the removal.
|
||||||
|
|
||||||
|
**Decision:** CLI registration pattern = `register<Name>Command(parent: Command)` exported by each sub-package, co-located with the library code. Proven by `@mosaicstack/quality-rails` → `registerQualityRails(program)`. Avoids cross-package commander version mismatches.
|
||||||
|
|
||||||
|
**Decision:** Stale mission state (harness-20260321 manifest, storage-abstraction TASKS.md, PRD-Harness_Foundation.md) gets archived under `docs/archive/missions/`. Scratchpads for completed sub-missions are left in `docs/scratchpads/` as historical record — they're append-only by design and valuable as breadcrumbs.
|
||||||
|
|
||||||
|
### 2026-04-04 — Gateway bootstrap token bug root cause
|
||||||
|
|
||||||
|
`apps/gateway/src/admin/bootstrap.controller.ts`:
|
||||||
|
|
||||||
|
- `GET /api/bootstrap/status` returns `needsSetup: true` **only** when `users` table count is zero
|
||||||
|
- `POST /api/bootstrap/setup` throws `ForbiddenException` if any user exists
|
||||||
|
|
||||||
|
`packages/mosaic/src/commands/gateway/install.ts` — `runInstall()` "explicit reinstall" branch (lines ~87–98):
|
||||||
|
|
||||||
|
1. Clears `meta.adminToken` from meta.json (line 175 — `preserveToken = false` when `regeneratedConfig = true`)
|
||||||
|
2. Calls `bootstrapFirstUser()`
|
||||||
|
3. Status endpoint returns `needsSetup: false` because users row still exists
|
||||||
|
4. `bootstrapFirstUser` prints _"Admin user already exists — skipping setup. (No admin token on file — sign in via the web UI to manage tokens.)"_ and returns
|
||||||
|
5. Install "succeeds" with NO token, NO CLI path to generate one, and chicken-and-egg on `/api/admin/tokens` which requires auth
|
||||||
|
|
||||||
|
**Recovery design options (to decide in CU-03-01):**
|
||||||
|
|
||||||
|
- Filesystem-signed nonce file written by the installer; recovery endpoint checks it
|
||||||
|
- Accept a valid BetterAuth admin session cookie → mint new admin token via authenticated API call (leans on existing auth; `mosaic gateway login` becomes the recovery entry point)
|
||||||
|
- Gateway daemon accepts `--rescue` flag that mints a one-shot recovery token, prints it, then exits
|
||||||
|
|
||||||
|
Current lean: option 2 (BetterAuth cookie) because it reuses existing auth and gives us `mosaic gateway login` as a useful command regardless. But the design spike in CU-03-01 should evaluate all three against: security, complexity, headless-environment friendliness, and disaster-recovery scenarios.
|
||||||
|
|
||||||
|
### 2026-04-04 — Telemetry architecture
|
||||||
|
|
||||||
|
- `@mosaicstack/telemetry-client-js` + `@mosaicstack/telemetry-client-py` are separate repos on Gitea — **not** currently consumed anywhere in this monorepo (verified via grep)
|
||||||
|
- Telemetry server will be combined with the main mosaicstack.dev website (not built yet)
|
||||||
|
- Local OTEL stays — `apps/gateway/src/tracing.ts` already wires it up for wide-event logging and post-mortem traces
|
||||||
|
- `mosaic telemetry` is a thin wrapper that:
|
||||||
|
- `mosaic telemetry local {status,tail,jaeger}` → local OTEL state, Jaeger links
|
||||||
|
- `mosaic telemetry {status,opt-in,opt-out,test,upload}` → remote upload path via telemetry-client-js
|
||||||
|
- Remote disabled by default; opt-in requires explicit consent
|
||||||
|
- `test`/`upload` ship with dry-run mode until the server endpoint is live
|
||||||
|
|
||||||
|
### 2026-04-04 — Open-question decisions (session 1)
|
||||||
|
|
||||||
|
Jason answered the four planning questions:
|
||||||
|
|
||||||
|
1. **Recovery endpoint design (CU-03-01):** BetterAuth cookie. `mosaic gateway login` becomes the recovery entry point. The spike in CU-03-01 can be compressed — design is locked; task becomes implementation planning rather than evaluation.
|
||||||
|
2. **Sub-package command surface (M5):** The current CU-05-01..08 scope is acceptable for this mission. Deeper command surfaces can be follow-up work.
|
||||||
|
3. **Telemetry server:** Ship `mosaic telemetry upload` and `mosaic telemetry test` in dry-run-only mode until the mosaicstack.dev server endpoint is live. Capture intended payload shape and print/log instead of POSTing. Real upload path gets wired in as follow-up once the server is ready.
|
||||||
|
4. **Top-level `mosaic config`:** Required. Add to M4 (CLI structure milestone) since it lives alongside help-shape work and uses the existing `packages/mosaic/src/config/config-service.ts` machinery. Separate concern from `mosaic gateway config` (which manages gateway .env + meta.json).
|
||||||
|
|
||||||
|
## Session Log
|
||||||
|
|
||||||
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
|
| ------- | ---------- | ------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | 2026-04-04 | cu-m01 Kill legacy CLI | CU-01-01 | PR #398 merged to main as `c39433c3`. 48 files deleted, 6685 LOC removed. CI green (pipeline 702). |
|
||||||
|
| 1 | 2026-04-04 | cu-m02 Archive + scaffold | CU-02-01, CU-02-02, CU-02-03 | PR #399 merged to main as `6f15a84c`. Mission manifest + TASKS.md + scratchpad live. |
|
||||||
|
| 1 | 2026-04-04 | Planning | 4 open questions resolved | See decisions block above. Ready to start M3/M4/M5. |
|
||||||
|
|
||||||
|
## Corrections / Course Changes
|
||||||
|
|
||||||
|
_(append here as they happen)_
|
||||||
|
|
||||||
|
## Handoff — end of Session 1 (2026-04-04)
|
||||||
|
|
||||||
|
**Session 1 agent:** claude-opus-4-6[1m]
|
||||||
|
**Reason for handoff:** context budget (~80% used after bootstrap + two PRs + decision capture). Main is clean, no in-flight branches, no dirty state.
|
||||||
|
|
||||||
|
### What Session 2 should read first
|
||||||
|
|
||||||
|
1. `docs/MISSION-MANIFEST.md` — phase, progress, milestone table
|
||||||
|
2. `docs/TASKS.md` — task state, dependencies, agent assignments
|
||||||
|
3. This scratchpad — decisions, bug analysis, open risks, gotchas
|
||||||
|
4. `git log --oneline -5` — confirm #398 and #399 are on main
|
||||||
|
|
||||||
|
### State of the world
|
||||||
|
|
||||||
|
- **Main branch HEAD:** `6f15a84c docs: archive stale mission, scaffold CLI unification mission (#399)`
|
||||||
|
- **Working tree:** clean (no uncommitted changes after this handoff PR merges)
|
||||||
|
- **Open PRs:** none (both M1 and M2 PRs merged)
|
||||||
|
- **Deleted branches:** `chore/remove-cli-package-duplicate`, `docs/mission-cli-unification` (both local + remote)
|
||||||
|
- **Milestones done:** cu-m01, cu-m02 (2 / 8)
|
||||||
|
- **Milestones unblocked for parallel start:** cu-m03, cu-m04, cu-m05 (everything except M5.CU-05-06 which waits on M3.CU-03-03 for gateway login)
|
||||||
|
|
||||||
|
### Decisions locked (do not re-debate)
|
||||||
|
|
||||||
|
1. `@mosaicstack/cli` is dead; `@mosaicstack/mosaic` is the sole CLI package
|
||||||
|
2. Sub-package CLI pattern: each package exports `register<Name>Command(parent: Command)`, wired into `packages/mosaic/src/cli.ts` (copy the `registerQualityRails` pattern)
|
||||||
|
3. Gateway recovery uses **BetterAuth cookie** — `mosaic gateway login` + `mosaic gateway config rotate-token` via authenticated `POST /api/admin/tokens`
|
||||||
|
4. Telemetry: `mosaic telemetry` wraps `@mosaicstack/telemetry-client-js`; remote upload is dry-run only until the mosaicstack.dev server endpoint is live
|
||||||
|
5. Top-level `mosaic config` command is required (separate from `mosaic gateway config`) — wraps `packages/mosaic/src/config/config-service.ts`; added as CU-04-04
|
||||||
|
|
||||||
|
### Known gotchas for Session 2
|
||||||
|
|
||||||
|
- **pr-create.sh eval bug:** `~/.config/mosaic/tools/git/pr-create.sh` line 158 uses `eval "$CMD"`. Backticks and `$()` in PR bodies get shell-evaluated. **Workaround:** strip backticks from PR bodies OR use `tea pr create --repo mosaicstack/mosaic-stack --login mosaicstack --title ... --description ... --head <branch>` directly. Captured in openbrain.
|
||||||
|
- **ci-queue-wait.sh unknown state:** The wrapper reports `state=unknown` and returns immediately instead of waiting. Poll the PR pipeline manually with `~/.config/mosaic/tools/woodpecker/pipeline-list.sh` and grep for the PR branch.
|
||||||
|
- **pr-merge.sh branch delete:** `-d` flag is accepted but warns "branch deletion may need to be done separately". Delete via the Gitea API: `curl -X DELETE -H "Authorization: token $TOKEN" "https://git.mosaicstack.dev/api/v1/repos/mosaicstack/mosaic-stack/branches/<url-encoded-branch>"`.
|
||||||
|
- **Tea login not default:** `tea login list` shows `mosaicstack` with DEFAULT=false. Pass `--login mosaicstack` explicitly on every `tea` call.
|
||||||
|
- **`.mosaic/orchestrator/session.lock`:** auto-rewritten on every session launch. Shows up as dirty working tree on branch switch. Safe to `git checkout` the file before branching.
|
||||||
|
- **Dual install.ts files no longer exist:** M1 removed `packages/cli/src/commands/gateway/install.ts`. The canonical (and only) one is `packages/mosaic/src/commands/gateway/install.ts`. The "user exists, no token" bug (CU-03-06) is in this file around lines 388-394 (`bootstrapFirstUser`). The server-side gate is in `apps/gateway/src/admin/bootstrap.controller.ts` lines 28 and 35.
|
||||||
|
|
||||||
|
### Suggested starting task for Session 2
|
||||||
|
|
||||||
|
Pick based on what the user wants shipped first:
|
||||||
|
|
||||||
|
- **Highest user-impact:** M3 — fixes the install bug that made the user "off the reservation" in the first place. Start with CU-03-01 (implementation plan, opus-tier, 4K) → CU-03-02 (server endpoint, sonnet).
|
||||||
|
- **Quickest win:** M4.CU-04-01 — one-line `configureHelp({ sortSubcommands: true })`. 3K estimate. Good warm-up.
|
||||||
|
- **User priority stated in session 1:** M5.CU-05-01 — `mosaic forge`. Larger scope (18K), but user flagged Forge specifically as part of "E2E install to functional, with Mosaic Forge working".
|
||||||
|
|
||||||
|
Session 2 orchestrator should pick one, update TASKS.md status to `in-progress`, follow the standard cycle: plan → code → test → review → remediate → commit → push → PR → queue guard → merge. Mosaic hard gates apply.
|
||||||
|
|
||||||
|
### Files added / modified in Session 1
|
||||||
|
|
||||||
|
Session 1 touched only these files across PRs #398 and #399 plus this handoff PR:
|
||||||
|
|
||||||
|
- Deleted: `packages/cli/` (entire directory, 48 files)
|
||||||
|
- Archived: `docs/archive/missions/harness-20260321/MISSION-MANIFEST.md`, `docs/archive/missions/harness-20260321/PRD.md`, `docs/archive/missions/storage-abstraction/TASKS.md`
|
||||||
|
- Modified: `pnpm-workspace.yaml`, `tools/install.sh`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `docs/guides/user-guide.md`, `packages/mosaic/framework/defaults/README.md`
|
||||||
|
- Created: `docs/MISSION-MANIFEST.md`, `docs/TASKS.md`, `docs/scratchpads/cli-unification-20260404.md` (this file)
|
||||||
|
|
||||||
|
No code changes to `apps/`, `packages/mosaic/`, or any other runtime package. Session 2 starts fresh on the runtime code.
|
||||||
|
|
||||||
|
## Open Risks
|
||||||
|
|
||||||
|
- **Telemetry server not live:** CU-06-03 (`mosaic telemetry upload`) may need a dry-run stub until the server endpoint exists on mosaicstack.dev. Not blocking for this mission, but ships with reduced validation until then.
|
||||||
|
- **`mosaic auth` depends on gateway login:** CU-05-06 is gated by CU-03-03 (`mosaic gateway login`). Sequencing matters — do not start CU-05-06 until M3 is done or significantly underway.
|
||||||
|
- **pr-create.sh wrapper bug:** Discovered during M1 — `~/.config/mosaic/tools/git/pr-create.sh` line 158 uses `eval "$CMD"`, which shell-evaluates any backticks / `$(…)` / `${…}` in PR bodies. Workaround: strip backticks from PR bodies (use bold / italic / plain text instead), or use `tea pr create` directly. Captured in openbrain as gotcha. Should be fixed upstream in Mosaic tools repo at some point, but out of scope for this mission.
|
||||||
|
- **Mosaic coord / orchestrator session lock drift:** `.mosaic/orchestrator/session.lock` gets re-written every session launch and shows up as a dirty working tree on branch switch. Not blocking — just noise to ignore.
|
||||||
|
|
||||||
|
## Session 2 Log (2026-04-05)
|
||||||
|
|
||||||
|
**Session 2 agent:** claude-opus-4-6[1m]
|
||||||
|
**Mode:** parallel orchestration across worktrees
|
||||||
|
|
||||||
|
### Wave 1 — M3 (gateway token recovery)
|
||||||
|
|
||||||
|
- CU-03-01 plan landed as PR #401 → `docs/plans/gateway-token-recovery.md`. Confirmed no server changes needed — AdminGuard already accepts BetterAuth cookies, `POST /api/admin/tokens` is the existing mint endpoint.
|
||||||
|
- CU-03-02..07 implemented as PR #411: `mosaic gateway login` (interactive BetterAuth sign-in, session persisted), `mosaic gateway config rotate-token`, `mosaic gateway config recover-token`, fix for `bootstrapFirstUser` "user exists, no token" dead-end, 22 new unit tests. New files: `commands/gateway/login.ts`, `commands/gateway/token-ops.ts`.
|
||||||
|
- CU-03-08 independent code review surfaced 2 BLOCKER findings (session.json world-readable, password echoed during prompt) + 3 important findings (trimmed password, cross-gateway token persistence, unsafe `--password` flag). Remediated in PR #414: `saveSession` writes mode 0o600, new `promptSecret()` uses TTY raw mode, persistence target now matches `--gateway` host, `--password` marked UNSAFE with warning.
|
||||||
|
|
||||||
|
### Wave 2 — M4 (help ergonomics + mosaic config)
|
||||||
|
|
||||||
|
- CU-04-01..03 landed as PR #402: `configureHelp({ sortSubcommands: true })` on root + gateway subgroup, plus an `addHelpText('after', …)` grouped-reference section (Commander 13 has no native command-group API).
|
||||||
|
- CU-04-04/05 landed as PR #408: top-level `mosaic config` with `show|get|set|edit|path`, extends `config/config-service.ts` with `readAll`, `getValue`, `setValue`, `getConfigPath`, `isInitialized` + `ConfigSection`/`ResolvedConfig` types. Additive only.
|
||||||
|
|
||||||
|
### Wave 3 — M5 (sub-package CLI surface, 8 commands + integration)
|
||||||
|
|
||||||
|
Parallel-dispatched in isolated worktrees. All merged:
|
||||||
|
|
||||||
|
- PR #403 `mosaic brain`, PR #404 `mosaic queue`, PR #405 `mosaic storage`, PR #406 `mosaic memory`, PR #407 `mosaic log`, PR #410 `mosaic macp`, PR #412 `mosaic forge`, PR #413 `mosaic auth`.
|
||||||
|
- Every package exports `register<Name>Command(parent: Command)` co-located with library code, following `@mosaicstack/quality-rails` pattern. Each wired into `packages/mosaic/src/cli.ts` with alphabetized `register…Command(program)` calls.
|
||||||
|
- PR #415 landed CU-05-10 integration smoke test (`packages/mosaic/src/cli-smoke.spec.ts`, 19 tests covering all 9 registrars) PLUS a pre-existing exports bug fix in `packages/macp/package.json` (`default` pointed at `./src/index.ts` instead of `./dist/index.js`, breaking ERR_MODULE_NOT_FOUND when compiled mosaic CLI tried to load macp at runtime). Caught by empirical `node packages/mosaic/dist/cli.js --help` test before merge.
|
||||||
|
|
||||||
|
### New gotchas captured in Session 2
|
||||||
|
|
||||||
|
- **`pr-create.sh` "Remote repository required" failure:** wrapper can't detect origin in multi-remote contexts. Fallback used throughout: direct Gitea API `curl -X POST …/api/v1/repos/mosaicstack/mosaic-stack/pulls` with body JSON.
|
||||||
|
- **`publish` workflow killed on post-merge pushes:** pipelines 735, 742, 747, 750, 758, 767 all show the Docker build step killed after `ci` workflow succeeded. Pre-existing infrastructure issue (observed on #714/#715 pre-mission). The `ci` workflow is the authoritative gate; `publish` killing is noise.
|
||||||
|
- **macp exports.default misaligned:** latent bug from original monorepo consolidation — every other package already pointed at `dist/`. Only exposed when compiled CLI started loading macp at runtime.
|
||||||
|
- **Commander 13 grouping:** no native command-group API; workaround is `addHelpText('after', groupedReferenceString)` + alphabetized flat list via `sortSubcommands: true`.
|
||||||
|
|
||||||
|
### Wave 4 — M6 + M7 (parallel)
|
||||||
|
|
||||||
|
- M6 `mosaic telemetry` landed as PR #417 (merge `a531029c`). Full scope CU-06-01..05: `@mosaicstack/telemetry-client-js` shim, `telemetry local {status,tail,jaeger}`, top-level `telemetry {status,opt-in,opt-out,test,upload}` with dry-run default, persistent consent state. New files: `packages/mosaic/src/commands/telemetry.ts`, `src/telemetry/client-shim.ts`, `src/telemetry/consent-store.ts`, plus `telemetry.spec.ts`.
|
||||||
|
- M7 unified first-run UX landed as PR #418 (merge `872c1245`). Full scope CU-07-01..04: `install.sh` `--yes`/`--no-auto-launch` flags + auto-handoff to wizard + gateway install, wizard/gateway-install coordination via transient state file, `mosaic gateway verify` post-install healthcheck, Docker-based `tools/e2e-install-test.sh`.
|
||||||
|
|
||||||
|
### Wave 5 — M8 (release)
|
||||||
|
|
||||||
|
- PR #419 (merge `b9d464de`) — CLI unification release v0.1.0. Single cohesive docs + release PR:
|
||||||
|
- README.md: unified command tree, new install UX, `mosaic gateway` and `mosaic config` sections, removed stale `@mosaicstack/cli` refs.
|
||||||
|
- docs/guides/user-guide.md: new "Sub-package Commands" + "Telemetry" sections covering all 11 top-level commands.
|
||||||
|
- `packages/mosaic/package.json`: bumped 0.0.21 → 0.1.0 (CI publishes on merge).
|
||||||
|
- Git tag: `mosaic-v0.1.0` (scoped to avoid collision with existing `v0.1.0` repo tag) — pushed to origin on merge sha.
|
||||||
|
- Gitea release: https://git.mosaicstack.dev/mosaicstack/mosaic-stack/releases/tag/mosaic-v0.1.0 — "@mosaicstack/mosaic v0.1.0 — CLI Unification".
|
||||||
|
|
||||||
|
### Wave 6 — M8 correction (version regression)
|
||||||
|
|
||||||
|
PR #419 bumped `@mosaicstack/mosaic` 0.0.21 → 0.1.0 and released as `mosaic-v0.1.0`. This was wrong on two counts:
|
||||||
|
|
||||||
|
1. **Versioning policy violation.** The project stays in `0.0.x` alpha until GA. Minor bump to `0.1.0` jumped out of alpha without authorization.
|
||||||
|
2. **macp exports fix never reached the registry.** PR #415 fixed `packages/macp/package.json` `exports.default` pointing at `./src/index.ts`, but did NOT bump macp's version. When the post-merge publish workflow ran on #419, it published `@mosaicstack/mosaic@0.1.0` but `@mosaicstack/macp@0.0.2` was "already published" so the fix was silently skipped. Result: users running `mosaic update` got mosaic 0.1.0 which depends on macp and resolves to the still-broken registry copy of macp@0.0.2, failing with `ERR_MODULE_NOT_FOUND` on `./src/index.ts` at CLI startup.
|
||||||
|
|
||||||
|
Correction PR:
|
||||||
|
|
||||||
|
- `@mosaicstack/mosaic` 0.1.0 → `0.0.22` (stay in alpha)
|
||||||
|
- `@mosaicstack/macp` 0.0.2 → `0.0.3` (force republish with the exports fix)
|
||||||
|
- Delete Gitea tag `mosaic-v0.1.0` + release
|
||||||
|
- Delete `@mosaicstack/mosaic@0.1.0` from the Gitea npm registry so `latest` reverts to the highest remaining version
|
||||||
|
- Create tag `mosaic-v0.0.22` + Gitea release
|
||||||
|
|
||||||
|
**Lesson captured:** every package whose _source_ changes must also have its _version_ bumped, because the publish workflow silently skips "already published" versions. `@mosaicstack/macp@0.0.2` had the bad exports in the registry from day one; the in-repo fix in #415 was invisible to installed-from-registry consumers until the version bumped.
|
||||||
|
|
||||||
|
### Wave 7 — Waves 2 & 3 correction (same systemic bug)
|
||||||
|
|
||||||
|
After Wave 6's correction (PR #421) landed `mosaic-v0.0.22`, a clean global install still crashed with `Named export 'registerBrainCommand' not found` — and after fixing brain/forge/log in PR #422, the next clean install crashed with `registerMemoryCommand` not found. Same root cause: M5 (PR #416) added `registerXCommand` exports to memory, queue, storage, brain, forge, log, and config but only bumped a subset of versions. The publish workflow silently skipped every unchanged-version package, leaving the M5 exports absent from the registry.
|
||||||
|
|
||||||
|
Three cascaded correction PRs were required because each attempt only surfaced the next stale package at runtime:
|
||||||
|
|
||||||
|
- **PR #421** — macp 0.0.2 → 0.0.3, mosaic 0.1.0 → 0.0.22, delete `mosaic-v0.1.0` tag/release/registry version
|
||||||
|
- **PR #422** — brain/forge/log 0.0.2 → 0.0.3, mosaic 0.0.22 → 0.0.23
|
||||||
|
- **PR #423** — memory/queue/storage 0.0.3 → 0.0.4, mosaic 0.0.23 → 0.0.24
|
||||||
|
|
||||||
|
**First clean end-to-end verification** after PR #423:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ npm i -g @mosaicstack/mosaic@latest # installs 0.0.24
|
||||||
|
$ mosaic --help # exits 0, prints full alphabetized command list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Systemic fix (follow-up):** The publish workflow's "already published, skipping" tolerance is dangerous when source changes without version bumps. Options to prevent recurrence: (a) fail publish if any workspace package's dist files differ from registry content at the same version, or (b) CI lint check that any `packages/*/src/**` change in a PR also modifies `packages/*/package.json` version.
|
||||||
|
|
||||||
|
### Mission outcome
|
||||||
|
|
||||||
|
All 8 milestones, all 8 success criteria met in-repo. Released as `mosaic-v0.0.24` (alpha) after three cascaded correction PRs (#421, #422, #423) fixing the same systemic publish-skip bug across macp, brain, forge, log, memory, queue, and storage. First version where `npm i -g @mosaicstack/mosaic@latest && mosaic --help` works end-to-end from a clean global install.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
### CU-01-01 (PR #398)
|
||||||
|
|
||||||
|
- Branch: `chore/remove-cli-package-duplicate`
|
||||||
|
- Commit: `7206b9411d96`
|
||||||
|
- Merge commit on main: `c39433c3`
|
||||||
|
- CI pipeline: #702 (`pull_request` event, all 6 steps green: postgres, install, typecheck, lint, format, test)
|
||||||
|
- Quality gates (pre-push): typecheck 38/38, lint 21/21, format clean, test 38/38
|
||||||
330
docs/scratchpads/install-ux-hardening-20260405.md
Normal file
330
docs/scratchpads/install-ux-hardening-20260405.md
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
# Install UX Hardening — IUH-M01 Session Notes
|
||||||
|
|
||||||
|
## Session: 2026-04-05 (agent-ad6b6696)
|
||||||
|
|
||||||
|
### Plan
|
||||||
|
|
||||||
|
**Manifest schema decision:**
|
||||||
|
|
||||||
|
- Version 1 JSON at `~/.config/mosaic/.install-manifest.json` (mode 0600)
|
||||||
|
- Written by `tools/install.sh` after successful install
|
||||||
|
- Fields: version, installedAt, cliVersion, frameworkVersion, mutations{directories, npmGlobalPackages, npmrcLines, shellProfileEdits, runtimeAssetCopies}
|
||||||
|
- Uninstall reads it; if missing → heuristic mode (warn user)
|
||||||
|
|
||||||
|
**File list:**
|
||||||
|
|
||||||
|
- NEW: `packages/mosaic/src/runtime/install-manifest.ts` — read/write helpers + types
|
||||||
|
- NEW: `packages/mosaic/src/runtime/install-manifest.spec.ts` — unit tests
|
||||||
|
- NEW: `packages/mosaic/src/commands/uninstall.ts` — command implementation
|
||||||
|
- NEW: `packages/mosaic/src/commands/uninstall.spec.ts` — unit tests
|
||||||
|
- MOD: `packages/mosaic/src/cli.ts` — register `uninstall` command
|
||||||
|
- MOD: `tools/install.sh` — write manifest on success + add `--uninstall` path
|
||||||
|
|
||||||
|
**Runtime asset list (from mosaic-link-runtime-assets / framework/install.sh):**
|
||||||
|
|
||||||
|
- `~/.claude/CLAUDE.md` (source: `$MOSAIC_HOME/runtime/claude/CLAUDE.md`)
|
||||||
|
- `~/.claude/settings.json` (source: `$MOSAIC_HOME/runtime/claude/settings.json`)
|
||||||
|
- `~/.claude/hooks-config.json` (source: `$MOSAIC_HOME/runtime/claude/hooks-config.json`)
|
||||||
|
- `~/.claude/context7-integration.md` (source: `$MOSAIC_HOME/runtime/claude/context7-integration.md`)
|
||||||
|
- `~/.config/opencode/AGENTS.md` (source: `$MOSAIC_HOME/runtime/opencode/AGENTS.md`)
|
||||||
|
- `~/.codex/instructions.md` (source: `$MOSAIC_HOME/runtime/codex/instructions.md`)
|
||||||
|
|
||||||
|
**Reversal logic:**
|
||||||
|
|
||||||
|
1. If `.mosaic-bak-<stamp>` exists for a file → restore it
|
||||||
|
2. Else if managed copy exists → remove it
|
||||||
|
3. Never touch files not in the known list
|
||||||
|
|
||||||
|
**npmrc reversal:**
|
||||||
|
|
||||||
|
- Only remove line `@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`
|
||||||
|
- If manifest has the line, use that as authoritative; else check heuristically
|
||||||
|
|
||||||
|
**PATH reversal:**
|
||||||
|
|
||||||
|
- Check install.sh: it does NOT add PATH entries to shell profiles (framework/install.sh migration removes old `$MOSAIC_HOME/bin` PATH entries in v0/v1→v2 migration, but new install does NOT add PATH)
|
||||||
|
- ASSUMPTION: No PATH edits in current install (v0.0.24+). Shell profiles not modified by current install.
|
||||||
|
- The `$PREFIX/bin` is mentioned in a warning but NOT added to shell profiles by install.sh.
|
||||||
|
- shellProfileEdits array will be empty for new installs; heuristic mode also skips it.
|
||||||
|
|
||||||
|
**Test strategy:**
|
||||||
|
|
||||||
|
- Unit test manifest read/write with temp dir mocking
|
||||||
|
- Unit test command registration
|
||||||
|
- Unit test dry-run flag (no actual fs mutations)
|
||||||
|
- Unit test --keep-data skips protected paths
|
||||||
|
- Unit test heuristic mode warning
|
||||||
|
|
||||||
|
**Implementation order:**
|
||||||
|
|
||||||
|
1. install-manifest.ts helpers
|
||||||
|
2. install-manifest.spec.ts tests
|
||||||
|
3. uninstall.ts command
|
||||||
|
4. uninstall.spec.ts tests
|
||||||
|
5. cli.ts registration
|
||||||
|
6. tools/install.sh manifest writing + --uninstall path
|
||||||
|
|
||||||
|
ASSUMPTION: No PATH modifications in current install.sh (v0.0.24). Framework v0/v1→v2 migration cleaned old PATH entries but current install does not add new ones.
|
||||||
|
ASSUMPTION: `--uninstall` in install.sh handles framework + cli + npmrc only; gateway teardown deferred to `mosaic gateway uninstall`.
|
||||||
|
ASSUMPTION: Pi settings.json edits (skills paths) added by framework/install.sh are NOT reversed in this iteration — too risky to touch user Pi config without manifest evidence. Noted as follow-up.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 2 — 2026-04-05 (orchestrator resume)
|
||||||
|
|
||||||
|
### IUH-M01 completion summary
|
||||||
|
|
||||||
|
- **PR:** #429 merged as `25cada77`
|
||||||
|
- **CI:** green (Woodpecker)
|
||||||
|
- **Issue:** #425 closed
|
||||||
|
- **Files:** +1205 lines across 4 new + 2 modified + 1 docs
|
||||||
|
- **Tests:** 14 new, 170 total passing
|
||||||
|
|
||||||
|
### Follow-ups captured from worker report
|
||||||
|
|
||||||
|
1. **Pi settings.json reversal deferred** — worker flagged as too risky without manifest evidence. Future IUH task should add manifest entries for Pi settings mutations. Not blocking M02/M03.
|
||||||
|
2. **Pre-existing `cli-smoke.spec.ts` failure** — `@mosaicstack/brain` package entry resolution fails in Vitest. Unrelated to IUH-M01. Worth a separate issue later.
|
||||||
|
3. **`pr-create.sh` wrapper bug with multiline bodies** — wrapper evals body args as shell when they contain newlines/paths. Worker fell back to Gitea REST API. Same class of bug I hit earlier with `issue-create.sh`. Worth a tooling-team issue to fix both wrappers.
|
||||||
|
|
||||||
|
### Mission doc sync
|
||||||
|
|
||||||
|
cli-unification docs that were archived before the M01 subagent ran did not travel into the M01 PR (they were local, stashed before pull). Re-applying now:
|
||||||
|
|
||||||
|
- `docs/archive/missions/cli-unification-20260404/` (the old manifest + tasks)
|
||||||
|
- `docs/MISSION-MANIFEST.md` (new install-ux-hardening content)
|
||||||
|
- `docs/TASKS.md` (new install-ux-hardening content)
|
||||||
|
|
||||||
|
Committing as `docs: scaffold install-ux-hardening mission + archive cli-unification`.
|
||||||
|
|
||||||
|
### Next action
|
||||||
|
|
||||||
|
Delegate IUH-M02 to a sonnet subagent in an isolated worktree.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 3: 2026-04-05 (agent-a6ff34a5) — IUH-M02 Wizard Remediation
|
||||||
|
|
||||||
|
### Plan
|
||||||
|
|
||||||
|
**AC-3: Password masking + confirmation**
|
||||||
|
|
||||||
|
- New `packages/mosaic/src/prompter/masked-prompt.ts` — raw-mode stdin reader that suppresses echo, handles backspace/Ctrl+C/Enter.
|
||||||
|
- `bootstrapFirstUser` in `packages/mosaic/src/commands/gateway/install.ts`: replace `rl.question('Admin password...')` with `promptMaskedPassword()`, require confirm pass, keep min-8 validation.
|
||||||
|
- Headless path: when `MOSAIC_ASSUME_YES=1` or `!process.stdin.isTTY`, read `MOSAIC_ADMIN_PASSWORD` env var directly.
|
||||||
|
|
||||||
|
**AC-4a: Hooks preview stage**
|
||||||
|
|
||||||
|
- New `packages/mosaic/src/stages/hooks-preview.ts` — reads `hooks-config.json` from `state.sourceDir` or `state.mosaicHome`, displays each top-level hook category with name/trigger/command preview, prompts "Install these hooks? [Y/n]", stores result in `state.hooks`.
|
||||||
|
- `packages/mosaic/src/types.ts` — add `hooks?: { accepted: boolean; acceptedAt?: string }` to `WizardState`.
|
||||||
|
- `packages/mosaic/src/wizard.ts` — insert `hooksPreviewStage` between `runtimeSetupStage` and `skillsSelectStage`; skip if no claude runtime detected.
|
||||||
|
|
||||||
|
**AC-4b: `mosaic config hooks` subcommands**
|
||||||
|
|
||||||
|
- Add `hooks` subcommand group to `packages/mosaic/src/commands/config.ts`:
|
||||||
|
- `list`: reads `~/.claude/hooks-config.json`, shows hook names and enabled/disabled status
|
||||||
|
- `disable <name>`: prefixes matching hook key with `_disabled_` in the JSON
|
||||||
|
- `enable <name>`: removes `_disabled_` prefix if present
|
||||||
|
|
||||||
|
**AC-5: Headless install path**
|
||||||
|
|
||||||
|
- `runConfigWizard`: detect headless mode (`MOSAIC_ASSUME_YES=1` or `!process.stdin.isTTY`), read env vars with defaults, validate required vars, skip prompts entirely.
|
||||||
|
- `bootstrapFirstUser`: detect headless mode, read `MOSAIC_ADMIN_NAME/EMAIL/PASSWORD`, validate, proceed without prompts.
|
||||||
|
- Document env vars in `packages/mosaic/README.md` (create if absent).
|
||||||
|
|
||||||
|
### File list
|
||||||
|
|
||||||
|
NEW:
|
||||||
|
|
||||||
|
- `packages/mosaic/src/prompter/masked-prompt.ts`
|
||||||
|
- `packages/mosaic/src/prompter/masked-prompt.spec.ts`
|
||||||
|
- `packages/mosaic/src/stages/hooks-preview.ts`
|
||||||
|
- `packages/mosaic/src/stages/hooks-preview.spec.ts`
|
||||||
|
|
||||||
|
MODIFIED:
|
||||||
|
|
||||||
|
- `packages/mosaic/src/types.ts` — extend WizardState
|
||||||
|
- `packages/mosaic/src/wizard.ts` — wire hooksPreviewStage
|
||||||
|
- `packages/mosaic/src/commands/gateway/install.ts` — masked password + headless path
|
||||||
|
- `packages/mosaic/src/commands/config.ts` — add hooks subcommands
|
||||||
|
- `packages/mosaic/src/commands/config.spec.ts` — extend tests
|
||||||
|
- `packages/mosaic/README.md` — document env vars
|
||||||
|
|
||||||
|
### Assumptions
|
||||||
|
|
||||||
|
ASSUMPTION: `hooks-config.json` location is `<sourceDir>/framework/runtime/claude/hooks-config.json` during wizard (sourceDir is package root). Fall back to `<mosaicHome>/runtime/claude/hooks-config.json` for installed config.
|
||||||
|
ASSUMPTION: The `hooks` subcommands under `config` operate on `~/.claude/hooks-config.json` (the installed copy), not the package source.
|
||||||
|
ASSUMPTION: For the hooks preview stage, the "name" field displayed per hook entry is the top-level event key (e.g. "PostToolUse") plus the matcher from nested hooks array. This is the most user-readable representation given the hooks-config.json structure.
|
||||||
|
ASSUMPTION: `config hooks list/enable/disable` use `CLAUDE_HOME` env or `~/.claude` as the target directory for hooks files.
|
||||||
|
ASSUMPTION: The headless TTY detection (`!process.stdin.isTTY`) is sufficient; `MOSAIC_ASSUME_YES=1` is an explicit override for cases where stdin is a TTY but the user still wants non-interactive (e.g., scripted installs with piped terminal).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 4 — 2026-04-05 (orchestrator resume) — IUH-M02 closed, delegating IUH-M03
|
||||||
|
|
||||||
|
### IUH-M02 completion summary
|
||||||
|
|
||||||
|
- **PR:** #431 merged as `cd8b1f66`
|
||||||
|
- **CI:** green (Woodpecker)
|
||||||
|
- **Issue:** #426 closed
|
||||||
|
- **Acceptance criteria:** AC-3 (password mask), AC-4 (hooks visibility — consent recorded), AC-5 (headless path) all satisfied
|
||||||
|
- **New files:** `prompter/masked-prompt.ts`, `stages/hooks-preview.ts` (+ specs)
|
||||||
|
- **Modified:** `wizard.ts`, `types.ts` (`state.hooks`), `commands/gateway/install.ts`, `commands/config.ts`
|
||||||
|
|
||||||
|
### Follow-up captured from M02 agent
|
||||||
|
|
||||||
|
**Hooks consent is recorded but not enforced.** The `hooks-preview` stage sets `state.hooks.accepted` when the user confirms, but the finalize stage still unconditionally runs `mosaic-link-runtime-assets`, which copies `hooks-config.json` into `~/.claude/` regardless of consent. This is a soft gap — the user sees the prompt and can decline, but declining currently has no effect downstream.
|
||||||
|
|
||||||
|
Options for addressing:
|
||||||
|
|
||||||
|
- Fold into IUH-M03 (since M03 touches the finalize/install convergence path anyway)
|
||||||
|
- Spin a separate small follow-up issue after M03 lands
|
||||||
|
|
||||||
|
Leaning toward folding into M03 — the unified first-run flow naturally reworks the finalize→gateway handoff where this gating belongs.
|
||||||
|
|
||||||
|
### IUH-M03 delegation
|
||||||
|
|
||||||
|
Now delegating to an **opus** subagent in an isolated worktree. Scope from `/tmp/iuh-m03-body.md`:
|
||||||
|
|
||||||
|
- Extract `runConfigWizard` → `stages/gateway-config.ts`
|
||||||
|
- Extract `bootstrapFirstUser` → `stages/gateway-bootstrap.ts`
|
||||||
|
- `runWizard` invokes gateway stages as final stages
|
||||||
|
- Drop the 10-minute `$XDG_RUNTIME_DIR/mosaic-install-state.json` session bridge
|
||||||
|
- `mosaic gateway install` becomes a thin standalone wrapper for backward-compat
|
||||||
|
- `tools/install.sh` single auto-launch entry point
|
||||||
|
- **Bonus if scoped:** honor `state.hooks.accepted` in finalize stage so declining hooks actually skips hook install
|
||||||
|
|
||||||
|
Known tooling caveats to pass to worker:
|
||||||
|
|
||||||
|
- `issue-create.sh` / `pr-create.sh` wrappers eval multiline bodies as shell — use Gitea REST API fallback with `load_credentials gitea-mosaicstack`
|
||||||
|
- Protected `main`: PR-only, squash merge
|
||||||
|
- Must run `ci-queue-wait.sh --purpose push|merge` before push/merge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 5: 2026-04-05 (agent-a7875fbd) — IUH-M03 Unified First-Run
|
||||||
|
|
||||||
|
### Problem recap
|
||||||
|
|
||||||
|
`mosaic wizard` and `mosaic gateway install` currently run as two separate phases bridged by a fragile 10-minute session file at `$XDG_RUNTIME_DIR/mosaic-install-state.json`. `tools/install.sh` auto-launches both sequentially so the user perceives two wizards stitched together; state is not shared, prompts are duplicated, and if the user walks away the bridge expires.
|
||||||
|
|
||||||
|
### Design decision — Option A: gateway install becomes terminal stages of `runWizard`
|
||||||
|
|
||||||
|
Two options on the table:
|
||||||
|
|
||||||
|
- (A) Extract `runConfigWizard` and `bootstrapFirstUser` into `stages/gateway-config.ts` and `stages/gateway-bootstrap.ts`, append them to `runWizard` as final stages, and make `mosaic gateway install` a thin wrapper that runs the same stages with an ephemeral state seeded from existing config.
|
||||||
|
- (B) Introduce a new top-level orchestrator that composes the wizard and gateway install as siblings.
|
||||||
|
|
||||||
|
**Chosen: Option A.** Rationale:
|
||||||
|
|
||||||
|
1. The wizard already owns a `WizardState` that threads state across stages — gateway config/bootstrap fit naturally as additional stages without a new orchestration layer.
|
||||||
|
2. `mosaic gateway install` as standalone entry point stays idempotent by seeding a minimal `WizardState` and running only the gateway stages, reusing the same functions.
|
||||||
|
3. Avoids a parallel state object and keeps the call graph linear; easier to test and to reason about the "one cohesive flow" UX goal.
|
||||||
|
4. Option B would leave `runWizard` and the gateway install as siblings that still need to share a state object — equivalent complexity without the narrative simplification.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
1. Extend `WizardState` with optional `gateway` slice: `{ tier, port, databaseUrl?, valkeyUrl?, anthropicKey?, corsOrigin, admin?: { name, email, password } }`. The admin password is held in memory only — never persisted to disk as part of the state object.
|
||||||
|
2. New `packages/mosaic/src/stages/gateway-config.ts` — pure stage that:
|
||||||
|
- Reads existing `.env`/`mosaic.config.json` if present (resume path) and sets state.
|
||||||
|
- Otherwise prompts via `WizardPrompter` (interactive) or reads env vars (headless).
|
||||||
|
- Writes `.env` and `mosaic.config.json`, starts the daemon, waits for health.
|
||||||
|
3. New `packages/mosaic/src/stages/gateway-bootstrap.ts` — pure stage that:
|
||||||
|
- Checks `/api/bootstrap/status`.
|
||||||
|
- If needsSetup, prompts for admin name/email/password (uses `promptMaskedConfirmed`) or reads env vars (headless); calls `/api/bootstrap/setup`; persists token in meta.
|
||||||
|
- If already setup, handles inline token recovery exactly as today.
|
||||||
|
4. `packages/mosaic/src/wizard.ts` — append gateway-config and gateway-bootstrap as stages 11 and 12. Remove `writeInstallState` and the `INSTALL_STATE_FILE` constant entirely.
|
||||||
|
5. `packages/mosaic/src/commands/gateway/install.ts` — becomes a thin wrapper that builds a minimal `WizardState` with a `ClackPrompter`, then calls `runGatewayConfigStage(...)` and `runGatewayBootstrapStage(...)` directly. Remove the session-file readers/writers. Headless detection is delegated to the stage itself. The wrapper still exposes the `runInstall({host, port, skipInstall})` API so `gateway.ts` command registration is unchanged.
|
||||||
|
6. `tools/install.sh` — drop the second `mosaic gateway install` call; `mosaic wizard` now covers end-to-end. Leave `gateway install` guidance for non-auto-launch path so users still know the standalone entry point exists.
|
||||||
|
7. **Hooks gating (bonus — folded in):** `finalize.ts` already runs `mosaic-link-runtime-assets`. When `state.hooks?.accepted === false`, set `MOSAIC_SKIP_CLAUDE_HOOKS=1` in the env for the subprocess; teach the script to skip copying `hooks-config.json` when that env var is set. Other runtime assets (CLAUDE.md, settings.json, context7) still get linked.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
NEW:
|
||||||
|
|
||||||
|
- `packages/mosaic/src/stages/gateway-config.ts` (+ `.spec.ts`)
|
||||||
|
- `packages/mosaic/src/stages/gateway-bootstrap.ts` (+ `.spec.ts`)
|
||||||
|
|
||||||
|
MODIFIED:
|
||||||
|
|
||||||
|
- `packages/mosaic/src/types.ts` — extend WizardState with `gateway?:` slice
|
||||||
|
- `packages/mosaic/src/wizard.ts` — append gateway stages, remove session-file bridge
|
||||||
|
- `packages/mosaic/src/commands/gateway/install.ts` — thin wrapper over stages, remove 10-min bridge
|
||||||
|
- `packages/mosaic/src/stages/finalize.ts` — honor `state.hooks.accepted === false` by setting `MOSAIC_SKIP_CLAUDE_HOOKS=1`
|
||||||
|
- `packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets` — honor `MOSAIC_SKIP_CLAUDE_HOOKS=1`
|
||||||
|
- `tools/install.sh` — single unified auto-launch
|
||||||
|
|
||||||
|
### Assumptions
|
||||||
|
|
||||||
|
ASSUMPTION: Gateway stages must run **after** `finalizeStage` because finalize writes identity files and links runtime assets that the gateway admin UX may later display — reversed ordering would leave Claude runtime linkage incomplete when the admin token banner prints.
|
||||||
|
ASSUMPTION: Standalone `mosaic gateway install` uses a `ClackPrompter` (interactive) by default; the headless path is still triggered by `MOSAIC_ASSUME_YES=1` or non-TTY stdin, and the stage functions detect this internally.
|
||||||
|
ASSUMPTION: When `runWizard` reaches the gateway stages, `state.mosaicHome` is authoritative for GATEWAY_HOME resolution if it differs from the default — we set `process.env.MOSAIC_GATEWAY_HOME` before importing gateway modules so the constants resolve correctly.
|
||||||
|
ASSUMPTION: Keeping backwards compatibility for `runInstall({host, port, skipInstall})` is enough — no other internal caller exists.
|
||||||
|
ASSUMPTION: Removing the session file is safe because the old bridge is at most a 10-minute window; there is no on-disk migration to do.
|
||||||
|
|
||||||
|
### Test plan
|
||||||
|
|
||||||
|
- `gateway-config.spec.ts`: fresh install writes .env + mosaic.config.json (mock fs + prompter); resume path reuses existing BETTER_AUTH_SECRET; headless path respects MOSAIC_STORAGE_TIER/MOSAIC_GATEWAY_PORT/etc.
|
||||||
|
- `gateway-bootstrap.spec.ts`: calls `/api/bootstrap/setup` with collected creds (mock fetch); handles "already setup" branch; honors headless env vars; persists token via `writeMeta`.
|
||||||
|
- Extend existing passing tests — no regressions in `login.spec`, `recover-token.spec`, `rotate-token.spec`.
|
||||||
|
- Unified flow integration is covered at the stage-level; no new e2e test infra required.
|
||||||
|
|
||||||
|
### Delivery cycle
|
||||||
|
|
||||||
|
plan (this entry) → code → typecheck/lint/format → test → codex review (`~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`) → remediate → commit → ci-queue-wait push → push → PR → CI green → merge → close #427.
|
||||||
|
|
||||||
|
### Remediation log (codex review rounds)
|
||||||
|
|
||||||
|
- **Round 1** — hooks opt-out did not remove an existing managed file; port override ignored on resume; headless errors swallowed. Fixed: hooks cleanup, `portOverride` honored, errors re-thrown.
|
||||||
|
- **Round 2** — headless stage failures exited 0; port override on decline-rerun mismatched; no default-path integration test. Fixed: `process.exit(1)` in headless, revert portOverride on decline, add `unified-wizard.test.ts`.
|
||||||
|
- **Round 3** — hooks removal too broad (would touch user-owned files); port override written to meta but not .env (drift); wizard swallowed errors. Fixed: `cmp -s` managed-file check, force regeneration when portOverride differs from saved port, re-throw unexpected errors.
|
||||||
|
- **Round 4** — port-override regeneration tripped the corrupt-partial-state guard (blocker); headless already-bootstrapped-with-no-local-token path reported failure instead of no-op; hooks byte-equality fragile across template updates. Fixed: introduce `forcePortRegen` flag bypassing the guard (with a dedicated spec test), headless rerun of already-bootstrapped gateway now returns `{ completed: true }` (with spec coverage), hooks cleanup now checks for a stable `"mosaic-managed": true` marker embedded in the template (byte-equality remains as a fallback for legacy installs).
|
||||||
|
- Round 5 codex review attempted but blocked by upstream usage limit (quota). Rerun after quota refresh if further findings appear; all round-4 findings are code-covered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 6 — 2026-04-05 (orchestrator close-out) — MISSION COMPLETE
|
||||||
|
|
||||||
|
### IUH-M03 completion summary (reported by opus delivery agent)
|
||||||
|
|
||||||
|
- **PR:** #433 merged as `732f8a49`
|
||||||
|
- **CI:** Woodpecker green on final rebased commit `f3d5ef8d`
|
||||||
|
- **Issue:** #427 closed with summary comment
|
||||||
|
- **Tests:** 219 passing (+15 net new), 24 files
|
||||||
|
- **Codex review:** 4 rounds applied and remediated; round 5 blocked by upstream quota — no known outstanding findings
|
||||||
|
|
||||||
|
### What shipped in M03
|
||||||
|
|
||||||
|
- NEW stages: `stages/gateway-config.ts`, `stages/gateway-bootstrap.ts` (extracted from the old monolithic `gateway/install.ts`)
|
||||||
|
- NEW integration test: `__tests__/integration/unified-wizard.test.ts`
|
||||||
|
- `runWizard` now has 12 stages — gateway config + bootstrap are terminal stages 11 & 12
|
||||||
|
- 10-minute `$XDG_RUNTIME_DIR/mosaic-install-state.json` session-file bridge **deleted**
|
||||||
|
- `mosaic gateway install` rewritten as a thin standalone wrapper invoking the same two stages — backward-compat preserved
|
||||||
|
- `WizardState.gateway?` slice carries host/port/tier/admin/adminTokenIssued across stages
|
||||||
|
- `tools/install.sh` single unified `mosaic wizard` call — no more two-phase launch
|
||||||
|
- **Bonus scoped in:** finalize stage honors `state.hooks.accepted === false` via `MOSAIC_SKIP_CLAUDE_HOOKS=1`; `mosaic-link-runtime-assets` honors the flag; Mosaic-managed detection now uses a stable `"mosaic-managed": true` marker in `hooks-config.json` with byte-equality fallback for legacy installs. **Closes the M02 follow-up.**
|
||||||
|
|
||||||
|
### Mission status — ALL DONE
|
||||||
|
|
||||||
|
| AC | Status | PR |
|
||||||
|
| ---- | ------ | ---------------------------------------------------- |
|
||||||
|
| AC-1 | ✓ | #429 |
|
||||||
|
| AC-2 | ✓ | #429 |
|
||||||
|
| AC-3 | ✓ | #431 |
|
||||||
|
| AC-4 | ✓ | #431 + #433 (gating) |
|
||||||
|
| AC-5 | ✓ | #431 |
|
||||||
|
| AC-6 | ✓ | #433 |
|
||||||
|
| AC-7 | ✓ | #429, #431, #433 all merged, CI green, issues closed |
|
||||||
|
|
||||||
|
### Follow-ups for future work (not blocking mission close)
|
||||||
|
|
||||||
|
1. **`pr-ci-wait.sh` vs Woodpecker**: wrapper reports `state=unknown` because Woodpecker doesn't publish to Gitea's combined-status endpoint. Worker used `tea pr` CI glyphs as authoritative. Pre-existing tooling gap — worth a separate tooling-team issue.
|
||||||
|
2. **`issue-create.sh` / `pr-create.sh` wrapper `eval` bug with multiline bodies** — hit by M01, M02, M03 workers. All fell back to Gitea REST API. Needs wrapper fix.
|
||||||
|
3. **Codex review round 5** — attempted but blocked by upstream quota. Rerun after quota resets to confirm nothing else surfaces.
|
||||||
|
4. **Pi settings.json reversal** — deferred from M01; install manifest schema should be extended to track Pi settings mutations for reversal.
|
||||||
|
5. **`cli-smoke.spec.ts` pre-existing failure** — `@mosaicstack/brain` resolution in Vitest. Unrelated. Worth a separate issue.
|
||||||
|
|
||||||
|
### Next steps (orchestrator)
|
||||||
|
|
||||||
|
1. This scratchpad + MISSION-MANIFEST.md + TASKS.md updates → final docs PR
|
||||||
|
2. After merge: create release tag per framework rule (milestone/mission completion = release tag + repository release)
|
||||||
|
3. Archive mission docs under `docs/archive/missions/install-ux-hardening-20260405/` once the tag is published
|
||||||
173
docs/scratchpads/install-ux-v2-20260405.md
Normal file
173
docs/scratchpads/install-ux-v2-20260405.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Install UX v2 — Orchestrator Scratchpad
|
||||||
|
|
||||||
|
## Session 1 — 2026-04-05 (orchestrator scaffold)
|
||||||
|
|
||||||
|
### Trigger
|
||||||
|
|
||||||
|
Real-run testing of `@mosaicstack/mosaic@0.0.25` (fresh install of the release we just shipped from the parent mission `install-ux-hardening-20260405`) surfaced a critical regression and a cluster of UX failings. User feedback verbatim:
|
||||||
|
|
||||||
|
> The skill/additional feature installation section of install.sh is unsable
|
||||||
|
> The "quick-start" is asking way too many questions. This process should be much faster to get a quick start.
|
||||||
|
> The installater should have a main menu that allows for a drill-down install approach.
|
||||||
|
> "Plugins" — Install Recommended Plugins / Custom
|
||||||
|
> "Providers" — …
|
||||||
|
> The gateway port is not prefilling with 14242 for default
|
||||||
|
> What is the CORS origin for? Is that the webUI that isn't working yet? Maybe we should ask for the fqdn/hostname instead? There must be a better way to handle this.
|
||||||
|
|
||||||
|
Plus the critical bug, reproduced verbatim:
|
||||||
|
|
||||||
|
```
|
||||||
|
◇ Admin email
|
||||||
|
│ jason@woltje.com
|
||||||
|
Admin password (min 8 chars): ****************
|
||||||
|
Confirm password: ****************
|
||||||
|
│
|
||||||
|
▲ Bootstrap failed (400): {"message":["property email should not exist","property password should not exist"],"error":"Bad Request","statusCode":400}
|
||||||
|
✔ Wizard complete.
|
||||||
|
✔ Install manifest written: /home/jarvis/.config/mosaic/.install-manifest.json
|
||||||
|
|
||||||
|
✔ Done.
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the `✔ Wizard complete` and `✔ Done` lines **after** the 400. That's a second bug — failure didn't propagate in interactive mode.
|
||||||
|
|
||||||
|
### Diagnosis — orchestrator pre-scope
|
||||||
|
|
||||||
|
To avoid handing workers a vague prompt, pre-identified the concrete fix sites:
|
||||||
|
|
||||||
|
**Bug 1 (critical) — DTO class erasure.** `apps/gateway/src/admin/bootstrap.controller.ts:16`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { BootstrapSetupDto, BootstrapStatusDto, BootstrapResultDto } from './bootstrap.dto.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
`import type` erases the class at runtime. `@Body() dto: BootstrapSetupDto` then has no runtime metatype — `design:paramtypes` reflects `Object`. Nest's `ValidationPipe` with `whitelist: true` + `forbidNonWhitelisted: true` receives a plain Object metatype, treats every incoming property as non-whitelisted, and 400s with `"property email should not exist", "property password should not exist"`.
|
||||||
|
|
||||||
|
**One-character fix:** drop the `type` keyword on the `BootstrapSetupDto` import. `BootstrapStatusDto` and `BootstrapResultDto` are fine as type-only imports because they're used only in return type positions, not as `@Body()` metatypes.
|
||||||
|
|
||||||
|
Must be covered by an **integration test that binds through Nest**, not a controller unit test that imports the DTO directly — the unit test path would pass even with `import type` because it constructs the pipe manually. An e2e test with `@nestjs/testing` + `supertest` against the real `/api/bootstrap/setup` endpoint is the right guard.
|
||||||
|
|
||||||
|
**Bug 2 — interactive silent failure.** `packages/mosaic/src/wizard.ts:147-150`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (!bootstrapResult.completed && headlessRun) {
|
||||||
|
prompter.warn('Admin bootstrap failed in headless mode — aborting wizard.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The guard is `&& headlessRun`. In interactive mode, `completed: false` is silently swallowed and the wizard continues to the success lines. Fix: propagate failure in both modes. Decision for the worker — either `throw` or `process.exit(1)` with a clear error.
|
||||||
|
|
||||||
|
**Bug 3 — port prefill.** `packages/mosaic/src/stages/gateway-config.ts:77-88`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const raw = await p.text({
|
||||||
|
message: 'Gateway port',
|
||||||
|
defaultValue: defaultPort.toString(),
|
||||||
|
...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The stage is passing `defaultValue`. Either the `WizardPrompter.text` adapter is dropping it, or the underlying `@clack/prompts` call expects `initialValue` (which actually prefills the buffer) vs `defaultValue` (which is used only if the user submits an empty string). Worker should verify the adapter and likely switch to `initialValue` semantics so the user sees `14242` in the field.
|
||||||
|
|
||||||
|
**Bug 4 — Pi SDK copy gap.** The `"What is Mosaic?"` intro text enumerates Claude Code, Codex, and OpenCode but never mentions Pi SDK, which is the actual agent runtime behind those frontends. Purely a copy edit — find the string, add Pi SDK.
|
||||||
|
|
||||||
|
### Mission shape
|
||||||
|
|
||||||
|
Three milestones, three tracks, different tiers:
|
||||||
|
|
||||||
|
1. **IUV-M01 Hotfix** (sonnet) — the four bugs above + release `mosaic-v0.0.26`. Small, fast, unblocks the 0.0.25 happy path.
|
||||||
|
2. **IUV-M02 UX polish** (sonnet) — CORS origin → FQDN/hostname abstraction; diagnose and rework the skill installer section. Diagnostic-heavy.
|
||||||
|
3. **IUV-M03 Provider-first intelligent flow** (opus) — the big one: drill-down main menu, Quick Start path that's actually quick, provider-first natural-language intake with agent self-naming (OpenClaw-style). Architectural.
|
||||||
|
|
||||||
|
Sequencing: strict. M01 ships first as a hotfix release (mosaic-v0.0.26). M02 is diagnostic-heavy and can share groundwork with M03 but ships separately for clean release notes. M03 is the architectural anchor and lands last as `mosaic-v0.0.27`.
|
||||||
|
|
||||||
|
### Open design questions (to be resolved by workers, not pre-decided)
|
||||||
|
|
||||||
|
- M01: does `process.exit(1)` vs `throw` matter for how `tools/install.sh` surfaces the error? Worker should check the install.sh call site and pick the behavior that surfaces cleanly.
|
||||||
|
- M03: what LLM call powers the intent intake, and what's the offline fallback? Options: (a) reuse the provider the user is configuring (chicken-and-egg — provider setup hasn't happened yet), (b) a bundled deterministic "advisor" that hard-codes common intents, (c) require a provider key up-front before intake. Design doc (IUV-03-01) must resolve.
|
||||||
|
- M03: is the "agent self-naming" persistent across all future `mosaic` invocations, or a per-session nickname? Probably persistent — lives in `~/.config/mosaic/agent.json` or similar. Worker to decide + document.
|
||||||
|
|
||||||
|
### Non-goals for this mission
|
||||||
|
|
||||||
|
- No GUI / web UI
|
||||||
|
- No registry / pipeline migration
|
||||||
|
- No multi-user / multi-tenant onboarding
|
||||||
|
- No rework of `mosaic uninstall` (stable from parent mission)
|
||||||
|
|
||||||
|
### Known tooling caveats (carry forward from parent mission)
|
||||||
|
|
||||||
|
- `issue-create.sh` / `pr-create.sh` wrappers have an `eval` bug with multiline bodies — use Gitea REST API fallback with `load_credentials gitea-mosaicstack`
|
||||||
|
- `pr-ci-wait.sh` reports `state=unknown` against Woodpecker (combined-status endpoint gap) — use `tea pr` glyphs or poll the commit status endpoint directly
|
||||||
|
- Protected `main`, squash-merge only, PR-required
|
||||||
|
- CI queue guard before push/merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge`
|
||||||
|
|
||||||
|
### Next action
|
||||||
|
|
||||||
|
1. Create Gitea issues for M01, M02, M03
|
||||||
|
2. Open the mission-scaffold docs PR (same pattern as parent mission's PR #430)
|
||||||
|
3. After merge, delegate IUV-M01 to a sonnet subagent in an isolated worktree with the concrete fix-site pointers above
|
||||||
|
|
||||||
|
## Session 2 — 2026-04-05 (IUV-M01 delivery + close-out)
|
||||||
|
|
||||||
|
### Outcome
|
||||||
|
|
||||||
|
IUV-M01 shipped. `mosaic-v0.0.26` released and registry latest confirmed `0.0.26`.
|
||||||
|
|
||||||
|
### PRs merged
|
||||||
|
|
||||||
|
| PR | Title | Merge |
|
||||||
|
| ---- | ------------------------------------------------------------------------ | -------- |
|
||||||
|
| #440 | fix: bootstrap hotfix — DTO erasure, wizard failure, port prefill, copy | 0ae932ab |
|
||||||
|
| #441 | fix: add vitest.config.ts to eslint allowDefaultProject (#440 build fix) | c08aa6fa |
|
||||||
|
| #442 | docs: mark IUV-M01 complete — mosaic-v0.0.26 released | 78388437 |
|
||||||
|
|
||||||
|
### Bugs fixed (all 4 in worker's PR #440)
|
||||||
|
|
||||||
|
1. **DTO class erasure** — `apps/gateway/src/admin/bootstrap.controller.ts:16` — dropped `type` from `import { BootstrapSetupDto }`. Guarded by new e2e test `bootstrap.e2e.spec.ts` (4 cases) that binds through a real Nest app with `ValidationPipe { whitelist, forbidNonWhitelisted }`. Test suite needed `unplugin-swc` in `apps/gateway/vitest.config.ts` to emit `decoratorMetadata` (tsx/esbuild can't).
|
||||||
|
2. **Wizard silent failure** — `packages/mosaic/src/wizard.ts` — removed the `&& headlessRun` guard so `!bootstrapResult.completed` now aborts in both modes.
|
||||||
|
3. **Port prefill** — root cause was clack's `defaultValue` vs `initialValue` semantics (`defaultValue` only fills on empty submit, `initialValue` prefills the buffer). Added an `initialValue` field to `WizardPrompter.text()` interface, threaded through clack and headless prompters, switched `gateway-config.ts` port/url prompts to use it.
|
||||||
|
4. **Pi SDK copy** — `packages/mosaic/src/stages/welcome.ts` — intro copy now lists Pi SDK.
|
||||||
|
|
||||||
|
### Mid-delivery hiccup — tsconfig/eslint cross-contamination
|
||||||
|
|
||||||
|
Worker's initial approach added `vitest.config.ts` to `apps/gateway/tsconfig.json`'s `include` to appease the eslint parser. That broke `pnpm --filter @mosaicstack/gateway build` with TS6059 (`vitest.config.ts` outside `rootDir: "src"`). The publish pipeline on the `#440` merge commit failed.
|
||||||
|
|
||||||
|
**Correct fix** (worker's PR #441): leave `tsconfig.json` clean (`include: ["src/**/*"]`) and instead add the file to `allowDefaultProject` in the root `eslint.config.mjs`. This keeps the tsc program strict while letting eslint resolve a parser project for the standalone config file.
|
||||||
|
|
||||||
|
**Pattern to remember**: when adding root-level `.ts` config files (vitest, build scripts) to a package with `rootDir: "src"`, the eslint parser project conflict is solved with `allowDefaultProject`, NEVER by widening tsconfig include. I had independently arrived at the same fix on a branch before the worker shipped #441 — deleted the duplicate.
|
||||||
|
|
||||||
|
### Residual follow-ups carried forward
|
||||||
|
|
||||||
|
1. Headless prompter fallback order: worker set `initialValue > defaultValue` in the headless path. Correct semantic, but any future headless test that explicitly depends on `defaultValue` precedence will need review.
|
||||||
|
2. Vitest + SWC decorator metadata pattern is now the blessed approach for NestJS e2e tests in this monorepo. Any other package that adds NestJS e2e tests should mirror `apps/gateway/vitest.config.ts`.
|
||||||
|
|
||||||
|
### Next action
|
||||||
|
|
||||||
|
- Close out orchestrator doc sync (this commit): mark M01 subtasks done in `TASKS.md`, update manifest phase to Execution, commit scratchpad session 2, PR to main.
|
||||||
|
- After merge, delegate IUV-M02 (sonnet, isolated worktree). Dependencies: IUV-02-01 (CORS→FQDN) starts unblocked since M01 is released; first real task for the M02 worker is diagnosing the skill installer failure modes (IUV-02-02) against the fresh 0.0.26 install.
|
||||||
|
|
||||||
|
## Session 3 — 2026-04-05 (IUV-M02 delivery + close-out)
|
||||||
|
|
||||||
|
### Outcome
|
||||||
|
|
||||||
|
IUV-M02 shipped. PR #444 merged (`172bacb3`), issue #437 closed. 18 new tests (13 CORS derivation, 5 skill sync).
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
**CORS → FQDN (IUV-02-01):**
|
||||||
|
|
||||||
|
- `packages/mosaic/src/stages/gateway-config.ts` — replaced raw "CORS origin" text prompt with "Web UI hostname" (default: `localhost`). Added HTTPS follow-up for remote hosts. Pure `deriveCorsOrigin(hostname, port, useHttps?)` function exported for testability.
|
||||||
|
- Headless: `MOSAIC_HOSTNAME` env var as friendly alternative; `MOSAIC_CORS_ORIGIN` still works as full override.
|
||||||
|
- `packages/mosaic/src/types.ts` — added `hostname?: string` to `GatewayState`.
|
||||||
|
|
||||||
|
**Skill installer rework (IUV-02-02 + IUV-02-03):**
|
||||||
|
|
||||||
|
- Root cause confirmed: `syncSkills()` in `finalize.ts` ignored `state.selectedSkills` entirely. The multiselect UI was a no-op.
|
||||||
|
- `packages/mosaic/src/stages/finalize.ts` — `syncSkills()` rewritten to accept `selectedSkills[]`, returns typed `SyncSkillsResult`, passes `MOSAIC_INSTALL_SKILLS` (colon-separated) as env var to the bash script.
|
||||||
|
- `packages/mosaic/framework/tools/_scripts/mosaic-sync-skills` — added bash associative array whitelist filter keyed on `MOSAIC_INSTALL_SKILLS`. When set, only whitelisted skills are linked. Empty/unset = all skills (legacy behavior preserved for `mosaic sync` outside wizard).
|
||||||
|
- Failure surfaces: silent `catch {}` replaced with typed error reporting through `p.warn()`.
|
||||||
|
|
||||||
|
### Next action
|
||||||
|
|
||||||
|
- Delegate IUV-M03 (opus, isolated worktree) — the architectural milestone: provider-first intelligent flow, drill-down main menu, Quick Start fast path, agent self-naming. This is the biggest piece of the mission.
|
||||||
227
docs/scratchpads/iuv-m03-design.md
Normal file
227
docs/scratchpads/iuv-m03-design.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# IUV-M03 Design: Provider-first intelligent flow + drill-down main menu
|
||||||
|
|
||||||
|
**Issue:** #438
|
||||||
|
**Branch:** `feat/install-ux-intent`
|
||||||
|
**Date:** 2026-04-05
|
||||||
|
|
||||||
|
## 1. New first-run state machine
|
||||||
|
|
||||||
|
The linear 12-stage interrogation is replaced with a menu-driven architecture.
|
||||||
|
|
||||||
|
### Flow overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Welcome banner
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Detect existing install (auto)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Main Menu (loop)
|
||||||
|
|-- Quick Start -> provider key + admin creds -> finalize
|
||||||
|
|-- Providers -> LLM API key config
|
||||||
|
|-- Agent Identity -> intent intake + naming (deterministic)
|
||||||
|
|-- Skills -> recommended / custom selection
|
||||||
|
|-- Gateway -> port, storage tier, hostname, CORS
|
||||||
|
|-- Advanced -> SOUL.md, USER.md, TOOLS.md, runtimes, hooks
|
||||||
|
|-- Finish & Apply -> finalize + gateway bootstrap
|
||||||
|
v
|
||||||
|
Done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Menu navigation
|
||||||
|
|
||||||
|
- Main menu is a `select` prompt. Each option drills into a sub-flow.
|
||||||
|
- Completing a section returns to the main menu.
|
||||||
|
- Menu items show completion state: `[done]` hint after configuration.
|
||||||
|
- `Finish & Apply` is always last and requires at minimum a provider key (or explicit skip).
|
||||||
|
- The menu tracks configured sections in `WizardState.completedSections`.
|
||||||
|
|
||||||
|
### Headless bypass
|
||||||
|
|
||||||
|
When `MOSAIC_ASSUME_YES=1` or `!process.stdin.isTTY`, the entire menu is skipped.
|
||||||
|
The wizard runs: defaults + env var overrides -> finalize -> gateway config -> bootstrap.
|
||||||
|
This preserves full backward compatibility with `tools/install.sh --yes`.
|
||||||
|
|
||||||
|
## 2. Quick Start path
|
||||||
|
|
||||||
|
Target: 3-5 questions max. Under 90 seconds for a returning user.
|
||||||
|
|
||||||
|
### Questions asked
|
||||||
|
|
||||||
|
1. **Provider API key** (Anthropic/OpenAI) - `text` prompt with paste support
|
||||||
|
2. **Admin email** - `text` prompt
|
||||||
|
3. **Admin password** - masked + confirmed
|
||||||
|
|
||||||
|
### Questions skipped (with defaults)
|
||||||
|
|
||||||
|
| Setting | Default | Rationale |
|
||||||
|
| ---------------------------- | ------------------------------- | ---------------------- |
|
||||||
|
| Agent name | "Mosaic" | Generic but branded |
|
||||||
|
| Port | 14242 | Standard default |
|
||||||
|
| Storage tier | local | No external deps |
|
||||||
|
| Hostname | localhost | Dev-first |
|
||||||
|
| CORS origin | http://localhost:3000 | Standard web UI port |
|
||||||
|
| Skills | recommended set | Curated by maintainers |
|
||||||
|
| Runtimes | auto-detected | No user input needed |
|
||||||
|
| Communication style | direct | Most popular choice |
|
||||||
|
| SOUL.md / USER.md / TOOLS.md | template defaults | Can customize later |
|
||||||
|
| Hooks | auto-install if Claude detected | Safe default |
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Quick Start selected
|
||||||
|
-> "Paste your LLM API key (Anthropic recommended):"
|
||||||
|
-> [auto-detect provider from key prefix: sk-ant-* = Anthropic, sk-* = OpenAI]
|
||||||
|
-> Apply all defaults
|
||||||
|
-> Run finalize (sync framework, write configs, link assets, sync skills)
|
||||||
|
-> Run gateway config (headless-style with defaults + provided key)
|
||||||
|
-> "Admin email:"
|
||||||
|
-> "Admin password:" (masked + confirm)
|
||||||
|
-> Run gateway bootstrap
|
||||||
|
-> Done
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Provider-first flow
|
||||||
|
|
||||||
|
Provider configuration (currently buried in gateway-config stage as "ANTHROPIC_API_KEY")
|
||||||
|
moves to a dedicated top-level menu item and is the first question in Quick Start.
|
||||||
|
|
||||||
|
### Provider detection
|
||||||
|
|
||||||
|
The API key prefix determines the provider:
|
||||||
|
|
||||||
|
- `sk-ant-api03-*` -> Anthropic (Claude)
|
||||||
|
- `sk-*` -> OpenAI
|
||||||
|
- Empty/skipped -> no provider (gateway starts without LLM access)
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
The provider key is stored in the gateway `.env` as `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`.
|
||||||
|
For Quick Start, this replaces the old interactive prompt in `collectAndWriteConfig`.
|
||||||
|
|
||||||
|
### Menu section: "Providers"
|
||||||
|
|
||||||
|
In the drill-down menu, "Providers" lets users:
|
||||||
|
|
||||||
|
1. Enter/change their API key
|
||||||
|
2. See which provider was detected
|
||||||
|
3. Optionally configure a second provider
|
||||||
|
|
||||||
|
For v0.0.27, we support Anthropic and OpenAI keys only. The key is stored
|
||||||
|
in `WizardState` and written during finalize.
|
||||||
|
|
||||||
|
## 4. Intent intake + naming (deterministic fallback - Option B)
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
At install time, the LLM provider may not be configured yet (chicken-and-egg).
|
||||||
|
We use **Option B: deterministic advisor** for the install wizard.
|
||||||
|
|
||||||
|
### Flow (Agent Identity menu section)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. "What will this agent primarily help you with?"
|
||||||
|
-> Select from presets:
|
||||||
|
- General purpose assistant
|
||||||
|
- Software development
|
||||||
|
- DevOps & infrastructure
|
||||||
|
- Research & analysis
|
||||||
|
- Content & writing
|
||||||
|
- Custom (free text description)
|
||||||
|
|
||||||
|
2. System proposes a thematic name based on selection:
|
||||||
|
- General purpose -> "Mosaic"
|
||||||
|
- Software development -> "Forge"
|
||||||
|
- DevOps & infrastructure -> "Sentinel"
|
||||||
|
- Research & analysis -> "Atlas"
|
||||||
|
- Content & writing -> "Muse"
|
||||||
|
- Custom -> "Mosaic" (default)
|
||||||
|
|
||||||
|
3. "Your agent will be named 'Forge'. Press Enter to accept or type a new name:"
|
||||||
|
-> User confirms or overrides
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
- Agent name -> `WizardState.soul.agentName` -> written to SOUL.md
|
||||||
|
- Intent category -> `WizardState.agentIntent` (new field) -> written to `~/.config/mosaic/agent.json`
|
||||||
|
|
||||||
|
### Post-install LLM-powered intake (future)
|
||||||
|
|
||||||
|
A future `mosaic configure identity` command can use the configured LLM to:
|
||||||
|
|
||||||
|
- Accept free-text intent description
|
||||||
|
- Generate an expounded persona
|
||||||
|
- Propose a contextual name
|
||||||
|
|
||||||
|
This is explicitly out of scope for the install wizard.
|
||||||
|
|
||||||
|
## 5. Headless backward-compat
|
||||||
|
|
||||||
|
### Supported env vars (unchanged)
|
||||||
|
|
||||||
|
| Variable | Used by |
|
||||||
|
| -------------------------- | ---------------------------------------------- |
|
||||||
|
| `MOSAIC_ASSUME_YES=1` | Skip all prompts, use defaults + env overrides |
|
||||||
|
| `MOSAIC_ADMIN_NAME` | Gateway bootstrap |
|
||||||
|
| `MOSAIC_ADMIN_EMAIL` | Gateway bootstrap |
|
||||||
|
| `MOSAIC_ADMIN_PASSWORD` | Gateway bootstrap |
|
||||||
|
| `MOSAIC_GATEWAY_PORT` | Gateway config |
|
||||||
|
| `MOSAIC_HOSTNAME` | Gateway config (CORS derivation) |
|
||||||
|
| `MOSAIC_CORS_ORIGIN` | Gateway config (full override) |
|
||||||
|
| `MOSAIC_STORAGE_TIER` | Gateway config (local/team) |
|
||||||
|
| `MOSAIC_DATABASE_URL` | Gateway config (team tier) |
|
||||||
|
| `MOSAIC_VALKEY_URL` | Gateway config (team tier) |
|
||||||
|
| `MOSAIC_ANTHROPIC_API_KEY` | Provider config |
|
||||||
|
|
||||||
|
### New env vars
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| --------------------- | ----------------------------------------- |
|
||||||
|
| `MOSAIC_AGENT_NAME` | Override agent name in headless mode |
|
||||||
|
| `MOSAIC_AGENT_INTENT` | Override intent category in headless mode |
|
||||||
|
|
||||||
|
### `tools/install.sh --yes`
|
||||||
|
|
||||||
|
The install script sets `MOSAIC_ASSUME_YES=1` and passes through env vars.
|
||||||
|
No changes needed to the script itself. The new wizard detects headless mode
|
||||||
|
at the top of `runWizard` and runs a linear path identical to the old flow.
|
||||||
|
|
||||||
|
## 6. Explicit non-goals
|
||||||
|
|
||||||
|
- **No GUI** — this is a terminal wizard only
|
||||||
|
- **No multi-user install** — single-user, single-machine
|
||||||
|
- **No registry changes** — npm publish flow is unchanged
|
||||||
|
- **No LLM calls during install** — deterministic fallback only
|
||||||
|
- **No new dependencies** — uses existing @clack/prompts and picocolors
|
||||||
|
- **No changes to gateway API** — only the wizard orchestration changes
|
||||||
|
- **No changes to tools/install.sh** — headless compat maintained via env vars
|
||||||
|
|
||||||
|
## 7. Implementation plan
|
||||||
|
|
||||||
|
### Files to modify
|
||||||
|
|
||||||
|
1. `packages/mosaic/src/types.ts` — add `MenuSection`, `AgentIntent`, `completedSections`, `agentIntent`, `providerKey`, `providerType` to WizardState
|
||||||
|
2. `packages/mosaic/src/wizard.ts` — replace linear flow with menu loop
|
||||||
|
3. `packages/mosaic/src/stages/mode-select.ts` — becomes the main menu
|
||||||
|
4. `packages/mosaic/src/stages/provider-setup.ts` — new: provider key collection
|
||||||
|
5. `packages/mosaic/src/stages/agent-intent.ts` — new: intent intake + naming
|
||||||
|
6. `packages/mosaic/src/stages/menu-gateway.ts` — new: gateway sub-menu wrapper
|
||||||
|
7. `packages/mosaic/src/stages/quick-start.ts` — new: quick start linear path
|
||||||
|
8. `packages/mosaic/src/constants.ts` — add intent presets and name mappings
|
||||||
|
9. `packages/mosaic/package.json` — version bump 0.0.26 -> 0.0.27
|
||||||
|
|
||||||
|
### Files to add (tests)
|
||||||
|
|
||||||
|
1. `packages/mosaic/src/stages/wizard-menu.spec.ts` — menu navigation tests
|
||||||
|
2. `packages/mosaic/src/stages/quick-start.spec.ts` — quick start path tests
|
||||||
|
3. `packages/mosaic/src/stages/agent-intent.spec.ts` — intent + naming tests
|
||||||
|
4. `packages/mosaic/src/stages/provider-setup.spec.ts` — provider detection tests
|
||||||
|
|
||||||
|
### Migration strategy
|
||||||
|
|
||||||
|
The existing stage functions remain intact. The menu system wraps them —
|
||||||
|
each menu item calls the appropriate stage function(s). The linear headless
|
||||||
|
path calls them in the same order as before.
|
||||||
@@ -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',
|
||||||
|
'apps/gateway/vitest.config.ts',
|
||||||
'packages/mosaic/__tests__/*.ts',
|
'packages/mosaic/__tests__/*.ts',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -73,6 +73,27 @@ Spawn a worker instead. No exceptions. No "quick fixes."
|
|||||||
- Wait for at least one worker to complete before spawning more
|
- Wait for at least one worker to complete before spawning more
|
||||||
- This optimizes token usage and reduces context pressure
|
- This optimizes token usage and reduces context pressure
|
||||||
|
|
||||||
|
## File Ownership & Partitioning (Hard Rule for Parallel Workers)
|
||||||
|
|
||||||
|
When dispatching parallel workers, the orchestrator MUST assign **non-overlapping file scopes** to each worker. File collisions between parallel workers cause merge conflicts, lost edits, and wasted tokens.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
1. **Exclusive file ownership.** Each file may be assigned to at most one active worker. The orchestrator records ownership in the worker dispatch (prompt or task definition).
|
||||||
|
2. **Partition by directory or module.** Prefer assigning entire directories/modules to one worker rather than splitting files within a directory across workers.
|
||||||
|
3. **Shared files are serialized.** If two tasks must modify the same file (e.g., a shared types file, a barrel export), they MUST run sequentially — never in parallel. Mark the second task with `depends_on` pointing to the first.
|
||||||
|
4. **Test files follow source ownership.** If Worker A owns `src/auth/login.ts`, Worker A also owns `src/auth/__tests__/login.test.ts`. Do not split source and test across workers.
|
||||||
|
5. **Config files are orchestrator-reserved.** Files like `package.json`, `tsconfig.json`, and CI config are owned by the orchestrator and modified only between worker cycles, never during parallel execution.
|
||||||
|
6. **Document ownership in dispatch.** When spawning a worker, include an explicit `Files:` section listing owned paths/globs. Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
Files (exclusive — do not touch files outside this scope):
|
||||||
|
- apps/web/src/components/auth/**
|
||||||
|
- apps/web/src/lib/auth.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Conflict recovery.** If a worker edits a file outside its scope, the orchestrator MUST flag the violation, assess the diff, and either revert the out-of-scope change or re-run the affected worker with the corrected file.
|
||||||
|
|
||||||
## Delegation Mode Selection
|
## Delegation Mode Selection
|
||||||
|
|
||||||
Choose one delegation mode at session start:
|
Choose one delegation mode at session start:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/agent"
|
"directory": "packages/agent"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/auth"
|
"directory": "packages/auth"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/brain",
|
"name": "@mosaicstack/brain",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/brain"
|
"directory": "packages/brain"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -22,7 +22,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/db": "workspace:^",
|
"@mosaicstack/db": "workspace:^",
|
||||||
"@mosaicstack/types": "workspace:*"
|
"@mosaicstack/types": "workspace:*",
|
||||||
|
"commander": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
|
|||||||
95
packages/brain/src/cli.spec.ts
Normal file
95
packages/brain/src/cli.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { registerBrainCommand } from './cli.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test: verifies the command tree is correctly registered.
|
||||||
|
* No database connection is opened — we only inspect Commander metadata.
|
||||||
|
*/
|
||||||
|
describe('registerBrainCommand', () => {
|
||||||
|
function buildProgram(): Command {
|
||||||
|
const program = new Command('mosaic');
|
||||||
|
// Prevent Commander from calling process.exit on parse errors during tests.
|
||||||
|
program.exitOverride();
|
||||||
|
registerBrainCommand(program);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('registers a top-level "brain" command', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const brainCmd = program.commands.find((c) => c.name() === 'brain');
|
||||||
|
expect(brainCmd).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers "brain projects" with "list" and "create" subcommands', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
|
||||||
|
const projectsCmd = brainCmd.commands.find((c) => c.name() === 'projects');
|
||||||
|
expect(projectsCmd).toBeDefined();
|
||||||
|
|
||||||
|
const subNames = projectsCmd!.commands.map((c) => c.name());
|
||||||
|
expect(subNames).toContain('list');
|
||||||
|
expect(subNames).toContain('create');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers "brain missions" with "list" subcommand', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
|
||||||
|
const missionsCmd = brainCmd.commands.find((c) => c.name() === 'missions');
|
||||||
|
expect(missionsCmd).toBeDefined();
|
||||||
|
|
||||||
|
const subNames = missionsCmd!.commands.map((c) => c.name());
|
||||||
|
expect(subNames).toContain('list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers "brain tasks" with "list" subcommand', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
|
||||||
|
const tasksCmd = brainCmd.commands.find((c) => c.name() === 'tasks');
|
||||||
|
expect(tasksCmd).toBeDefined();
|
||||||
|
|
||||||
|
const subNames = tasksCmd!.commands.map((c) => c.name());
|
||||||
|
expect(subNames).toContain('list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers "brain conversations" with "list" subcommand', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
|
||||||
|
const conversationsCmd = brainCmd.commands.find((c) => c.name() === 'conversations');
|
||||||
|
expect(conversationsCmd).toBeDefined();
|
||||||
|
|
||||||
|
const subNames = conversationsCmd!.commands.map((c) => c.name());
|
||||||
|
expect(subNames).toContain('list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"brain projects list" accepts --db and --limit options', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
|
||||||
|
const projectsCmd = brainCmd.commands.find((c) => c.name() === 'projects')!;
|
||||||
|
const listCmd = projectsCmd.commands.find((c) => c.name() === 'list')!;
|
||||||
|
|
||||||
|
const optionNames = listCmd.options.map((o) => o.long);
|
||||||
|
expect(optionNames).toContain('--db');
|
||||||
|
expect(optionNames).toContain('--limit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"brain missions list" accepts --project option', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
|
||||||
|
const missionsCmd = brainCmd.commands.find((c) => c.name() === 'missions')!;
|
||||||
|
const listCmd = missionsCmd.commands.find((c) => c.name() === 'list')!;
|
||||||
|
|
||||||
|
const optionNames = listCmd.options.map((o) => o.long);
|
||||||
|
expect(optionNames).toContain('--project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"brain tasks list" accepts --project option', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const brainCmd = program.commands.find((c) => c.name() === 'brain')!;
|
||||||
|
const tasksCmd = brainCmd.commands.find((c) => c.name() === 'tasks')!;
|
||||||
|
const listCmd = tasksCmd.commands.find((c) => c.name() === 'list')!;
|
||||||
|
|
||||||
|
const optionNames = listCmd.options.map((o) => o.long);
|
||||||
|
expect(optionNames).toContain('--project');
|
||||||
|
});
|
||||||
|
});
|
||||||
142
packages/brain/src/cli.ts
Normal file
142
packages/brain/src/cli.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import type { Command } from 'commander';
|
||||||
|
import { createDb, type DbHandle } from '@mosaicstack/db';
|
||||||
|
import { createBrain } from './brain.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build and attach the `brain` subcommand tree onto an existing Commander program.
|
||||||
|
* Uses the caller's Command instance to avoid cross-package Commander version mismatches.
|
||||||
|
*/
|
||||||
|
export function registerBrainCommand(parent: Command): void {
|
||||||
|
const brain = parent.command('brain').description('Inspect and manage brain data stores');
|
||||||
|
|
||||||
|
// ─── shared DB option helper ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function addDbOption(cmd: Command): Command {
|
||||||
|
return cmd.option(
|
||||||
|
'--db <connection-string>',
|
||||||
|
'PostgreSQL connection string (overrides MOSAIC_DB_URL)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDb(opts: { db?: string }): ReturnType<typeof createBrain> {
|
||||||
|
const connectionString = opts.db ?? process.env['MOSAIC_DB_URL'];
|
||||||
|
if (!connectionString) {
|
||||||
|
console.error('No DB connection string provided. Pass --db <url> or set MOSAIC_DB_URL.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const handle: DbHandle = createDb(connectionString);
|
||||||
|
return createBrain(handle.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── projects ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const projects = brain.command('projects').description('Manage projects');
|
||||||
|
|
||||||
|
addDbOption(
|
||||||
|
projects
|
||||||
|
.command('list')
|
||||||
|
.description('List all projects')
|
||||||
|
.option('--limit <n>', 'Maximum number of results', '50'),
|
||||||
|
).action(async (opts: { db?: string; limit: string }) => {
|
||||||
|
const b = resolveDb(opts);
|
||||||
|
const limit = parseInt(opts.limit, 10);
|
||||||
|
const rows = await b.projects.findAll();
|
||||||
|
const sliced = rows.slice(0, limit);
|
||||||
|
if (sliced.length === 0) {
|
||||||
|
console.log('No projects found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const p of sliced) {
|
||||||
|
console.log(`${p.id} ${p.name}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addDbOption(
|
||||||
|
projects
|
||||||
|
.command('create <name>')
|
||||||
|
.description('Create a new project')
|
||||||
|
.requiredOption('--owner-id <id>', 'Owner user ID'),
|
||||||
|
).action(async (name: string, opts: { db?: string; ownerId: string }) => {
|
||||||
|
const b = resolveDb(opts);
|
||||||
|
const created = await b.projects.create({
|
||||||
|
name,
|
||||||
|
ownerId: opts.ownerId,
|
||||||
|
ownerType: 'user',
|
||||||
|
});
|
||||||
|
console.log(`Created project: ${created.id} ${created.name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── missions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const missions = brain.command('missions').description('Manage missions');
|
||||||
|
|
||||||
|
addDbOption(
|
||||||
|
missions
|
||||||
|
.command('list')
|
||||||
|
.description('List all missions')
|
||||||
|
.option('--limit <n>', 'Maximum number of results', '50')
|
||||||
|
.option('--project <id>', 'Filter by project ID'),
|
||||||
|
).action(async (opts: { db?: string; limit: string; project?: string }) => {
|
||||||
|
const b = resolveDb(opts);
|
||||||
|
const limit = parseInt(opts.limit, 10);
|
||||||
|
const rows = opts.project
|
||||||
|
? await b.missions.findByProject(opts.project)
|
||||||
|
: await b.missions.findAll();
|
||||||
|
const sliced = rows.slice(0, limit);
|
||||||
|
if (sliced.length === 0) {
|
||||||
|
console.log('No missions found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const m of sliced) {
|
||||||
|
console.log(`${m.id} ${m.name}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── tasks ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const tasks = brain.command('tasks').description('Manage generic tasks');
|
||||||
|
|
||||||
|
addDbOption(
|
||||||
|
tasks
|
||||||
|
.command('list')
|
||||||
|
.description('List all tasks')
|
||||||
|
.option('--limit <n>', 'Maximum number of results', '50')
|
||||||
|
.option('--project <id>', 'Filter by project ID'),
|
||||||
|
).action(async (opts: { db?: string; limit: string; project?: string }) => {
|
||||||
|
const b = resolveDb(opts);
|
||||||
|
const limit = parseInt(opts.limit, 10);
|
||||||
|
const rows = opts.project ? await b.tasks.findByProject(opts.project) : await b.tasks.findAll();
|
||||||
|
const sliced = rows.slice(0, limit);
|
||||||
|
if (sliced.length === 0) {
|
||||||
|
console.log('No tasks found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const t of sliced) {
|
||||||
|
console.log(`${t.id} ${t.title} [${t.status}]`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── conversations ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const conversations = brain.command('conversations').description('Manage conversations');
|
||||||
|
|
||||||
|
addDbOption(
|
||||||
|
conversations
|
||||||
|
.command('list')
|
||||||
|
.description('List conversations for a user')
|
||||||
|
.option('--limit <n>', 'Maximum number of results', '50')
|
||||||
|
.requiredOption('--user-id <id>', 'User ID to scope the query'),
|
||||||
|
).action(async (opts: { db?: string; limit: string; userId: string }) => {
|
||||||
|
const b = resolveDb(opts);
|
||||||
|
const limit = parseInt(opts.limit, 10);
|
||||||
|
const rows = await b.conversations.findAll(opts.userId);
|
||||||
|
const sliced = rows.slice(0, limit);
|
||||||
|
if (sliced.length === 0) {
|
||||||
|
console.log('No conversations found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const c of sliced) {
|
||||||
|
console.log(`${c.id} ${c.title ?? '(untitled)'}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { createBrain, type Brain } from './brain.js';
|
export { createBrain, type Brain } from './brain.js';
|
||||||
|
export { registerBrainCommand } from './cli.js';
|
||||||
export {
|
export {
|
||||||
createProjectsRepo,
|
createProjectsRepo,
|
||||||
type ProjectsRepo,
|
type ProjectsRepo,
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@mosaicstack/cli",
|
|
||||||
"version": "0.0.17",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
|
||||||
"directory": "packages/cli"
|
|
||||||
},
|
|
||||||
"type": "module",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"types": "dist/index.d.ts",
|
|
||||||
"bin": {
|
|
||||||
"mosaic": "dist/cli.js"
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"default": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc -p tsconfig.build.json",
|
|
||||||
"dev": "tsx src/cli.ts",
|
|
||||||
"lint": "eslint src",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"test": "vitest run --passWithNoTests"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@clack/prompts": "^0.9.0",
|
|
||||||
"@mosaicstack/config": "workspace:^",
|
|
||||||
"@mosaicstack/mosaic": "workspace:^",
|
|
||||||
"@mosaicstack/prdy": "workspace:^",
|
|
||||||
"@mosaicstack/quality-rails": "workspace:^",
|
|
||||||
"@mosaicstack/types": "workspace:^",
|
|
||||||
"commander": "^13.0.0",
|
|
||||||
"ink": "^5.0.0",
|
|
||||||
"ink-spinner": "^5.0.0",
|
|
||||||
"ink-text-input": "^6.0.0",
|
|
||||||
"react": "^18.3.0",
|
|
||||||
"socket.io-client": "^4.8.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"@types/react": "^18.3.0",
|
|
||||||
"tsx": "^4.0.0",
|
|
||||||
"typescript": "^5.8.0",
|
|
||||||
"vitest": "^2.0.0"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
|
|
||||||
"access": "public"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
|
|
||||||
const SESSION_DIR = resolve(homedir(), '.mosaic');
|
|
||||||
const SESSION_FILE = resolve(SESSION_DIR, 'session.json');
|
|
||||||
|
|
||||||
interface StoredSession {
|
|
||||||
gatewayUrl: string;
|
|
||||||
cookie: string;
|
|
||||||
userId: string;
|
|
||||||
email: string;
|
|
||||||
expiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthResult {
|
|
||||||
cookie: string;
|
|
||||||
userId: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign in to the gateway and return the session cookie.
|
|
||||||
*/
|
|
||||||
export async function signIn(
|
|
||||||
gatewayUrl: string,
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
): Promise<AuthResult> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/auth/sign-in/email`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Origin: gatewayUrl },
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
redirect: 'manual',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text().catch(() => '');
|
|
||||||
throw new Error(`Sign-in failed (${res.status}): ${body}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract set-cookie header
|
|
||||||
const setCookieHeader = res.headers.getSetCookie?.() ?? [];
|
|
||||||
const sessionCookie = setCookieHeader
|
|
||||||
.map((c) => c.split(';')[0]!)
|
|
||||||
.filter((c) => c.startsWith('better-auth.session_token='))
|
|
||||||
.join('; ');
|
|
||||||
|
|
||||||
if (!sessionCookie) {
|
|
||||||
throw new Error('No session cookie returned from sign-in');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the response body for user info
|
|
||||||
const data = (await res.json()) as { user?: { id: string; email: string } };
|
|
||||||
const userId = data.user?.id ?? 'unknown';
|
|
||||||
const userEmail = data.user?.email ?? email;
|
|
||||||
|
|
||||||
return { cookie: sessionCookie, userId, email: userEmail };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save session to ~/.mosaic/session.json
|
|
||||||
*/
|
|
||||||
export function saveSession(gatewayUrl: string, auth: AuthResult): void {
|
|
||||||
if (!existsSync(SESSION_DIR)) {
|
|
||||||
mkdirSync(SESSION_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const session: StoredSession = {
|
|
||||||
gatewayUrl,
|
|
||||||
cookie: auth.cookie,
|
|
||||||
userId: auth.userId,
|
|
||||||
email: auth.email,
|
|
||||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days
|
|
||||||
};
|
|
||||||
|
|
||||||
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a saved session. Returns null if no session, expired, or wrong gateway.
|
|
||||||
*/
|
|
||||||
export function loadSession(gatewayUrl: string): AuthResult | null {
|
|
||||||
if (!existsSync(SESSION_FILE)) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = readFileSync(SESSION_FILE, 'utf-8');
|
|
||||||
const session = JSON.parse(raw) as StoredSession;
|
|
||||||
|
|
||||||
if (session.gatewayUrl !== gatewayUrl) return null;
|
|
||||||
if (new Date(session.expiresAt) < new Date()) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
cookie: session.cookie,
|
|
||||||
userId: session.userId,
|
|
||||||
email: session.email,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate that a stored session is still active by hitting get-session.
|
|
||||||
*/
|
|
||||||
export async function validateSession(gatewayUrl: string, cookie: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/auth/get-session`, {
|
|
||||||
headers: { Cookie: cookie, Origin: gatewayUrl },
|
|
||||||
});
|
|
||||||
return res.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,421 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import { createRequire } from 'module';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { registerQualityRails } from '@mosaicstack/quality-rails';
|
|
||||||
import { registerAgentCommand } from './commands/agent.js';
|
|
||||||
import { registerMissionCommand } from './commands/mission.js';
|
|
||||||
// prdy is registered via launch.ts
|
|
||||||
import { registerLaunchCommands } from './commands/launch.js';
|
|
||||||
import { registerGatewayCommand } from './commands/gateway.js';
|
|
||||||
|
|
||||||
const _require = createRequire(import.meta.url);
|
|
||||||
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('@mosaicstack/mosaic');
|
|
||||||
backgroundUpdateCheck();
|
|
||||||
} catch {
|
|
||||||
// Silently ignore — update check is best-effort
|
|
||||||
}
|
|
||||||
|
|
||||||
const program = new Command();
|
|
||||||
|
|
||||||
program.name('mosaic').description('Mosaic Stack CLI').version(CLI_VERSION);
|
|
||||||
|
|
||||||
// ─── runtime launchers + framework commands ────────────────────────────
|
|
||||||
|
|
||||||
registerLaunchCommands(program);
|
|
||||||
|
|
||||||
// ─── login ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('login')
|
|
||||||
.description('Sign in to a Mosaic gateway')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
|
||||||
.option('-e, --email <email>', 'Email address')
|
|
||||||
.option('-p, --password <password>', 'Password')
|
|
||||||
.action(async (opts: { gateway: string; email?: string; password?: string }) => {
|
|
||||||
const { signIn, saveSession } = await import('./auth.js');
|
|
||||||
|
|
||||||
let email = opts.email;
|
|
||||||
let password = opts.password;
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
const readline = await import('node:readline');
|
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
|
||||||
|
|
||||||
if (!email) email = await ask('Email: ');
|
|
||||||
if (!password) password = await ask('Password: ');
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const auth = await signIn(opts.gateway, email, password);
|
|
||||||
saveSession(opts.gateway, auth);
|
|
||||||
console.log(`Signed in as ${auth.email} (${opts.gateway})`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── tui ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('tui')
|
|
||||||
.description('Launch interactive TUI connected to the gateway')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
|
||||||
.option('-c, --conversation <id>', 'Resume a conversation by ID')
|
|
||||||
.option('-m, --model <modelId>', 'Model ID to use (e.g. gpt-4o, llama3.2)')
|
|
||||||
.option('-p, --provider <provider>', 'Provider to use (e.g. openai, ollama)')
|
|
||||||
.option('--agent <idOrName>', 'Connect to a specific agent')
|
|
||||||
.option('--project <idOrName>', 'Scope session to project')
|
|
||||||
.action(
|
|
||||||
async (opts: {
|
|
||||||
gateway: string;
|
|
||||||
conversation?: string;
|
|
||||||
model?: string;
|
|
||||||
provider?: string;
|
|
||||||
agent?: string;
|
|
||||||
project?: string;
|
|
||||||
}) => {
|
|
||||||
const { loadSession, validateSession, signIn, saveSession } = await import('./auth.js');
|
|
||||||
|
|
||||||
// Try loading saved session
|
|
||||||
let session = loadSession(opts.gateway);
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
const valid = await validateSession(opts.gateway, session.cookie);
|
|
||||||
if (!valid) {
|
|
||||||
console.log('Session expired. Please sign in again.');
|
|
||||||
session = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No valid session — prompt for credentials
|
|
||||||
if (!session) {
|
|
||||||
const readline = await import('node:readline');
|
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
const ask = (q: string): Promise<string> =>
|
|
||||||
new Promise((resolve) => rl.question(q, resolve));
|
|
||||||
|
|
||||||
console.log(`Sign in to ${opts.gateway}`);
|
|
||||||
const email = await ask('Email: ');
|
|
||||||
const password = await ask('Password: ');
|
|
||||||
rl.close();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const auth = await signIn(opts.gateway, email, password);
|
|
||||||
saveSession(opts.gateway, auth);
|
|
||||||
session = auth;
|
|
||||||
console.log(`Signed in as ${auth.email}\n`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve agent ID if --agent was passed by name
|
|
||||||
let agentId: string | undefined;
|
|
||||||
let agentName: string | undefined;
|
|
||||||
if (opts.agent) {
|
|
||||||
try {
|
|
||||||
const { fetchAgentConfigs } = await import('./tui/gateway-api.js');
|
|
||||||
const agents = await fetchAgentConfigs(opts.gateway, session.cookie);
|
|
||||||
const match = agents.find((a) => a.id === opts.agent || a.name === opts.agent);
|
|
||||||
if (match) {
|
|
||||||
agentId = match.id;
|
|
||||||
agentName = match.name;
|
|
||||||
} else {
|
|
||||||
console.error(`Agent "${opts.agent}" not found.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
`Failed to resolve agent: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve project ID if --project was passed by name
|
|
||||||
let projectId: string | undefined;
|
|
||||||
if (opts.project) {
|
|
||||||
try {
|
|
||||||
const { fetchProjects } = await import('./tui/gateway-api.js');
|
|
||||||
const projects = await fetchProjects(opts.gateway, session.cookie);
|
|
||||||
const match = projects.find((p) => p.id === opts.project || p.name === opts.project);
|
|
||||||
if (match) {
|
|
||||||
projectId = match.id;
|
|
||||||
} else {
|
|
||||||
console.error(`Project "${opts.project}" not found.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
`Failed to resolve project: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-create a conversation if none was specified
|
|
||||||
let conversationId = opts.conversation;
|
|
||||||
if (!conversationId) {
|
|
||||||
try {
|
|
||||||
const { createConversation } = await import('./tui/gateway-api.js');
|
|
||||||
const conv = await createConversation(opts.gateway, session.cookie, {
|
|
||||||
...(projectId ? { projectId } : {}),
|
|
||||||
});
|
|
||||||
conversationId = conv.id;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(
|
|
||||||
`Failed to create conversation: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamic import to avoid loading React/Ink for other commands
|
|
||||||
const { render } = await import('ink');
|
|
||||||
const React = await import('react');
|
|
||||||
const { TuiApp } = await import('./tui/app.js');
|
|
||||||
|
|
||||||
render(
|
|
||||||
React.createElement(TuiApp, {
|
|
||||||
gatewayUrl: opts.gateway,
|
|
||||||
conversationId,
|
|
||||||
sessionCookie: session.cookie,
|
|
||||||
initialModel: opts.model,
|
|
||||||
initialProvider: opts.provider,
|
|
||||||
agentId,
|
|
||||||
agentName: agentName ?? undefined,
|
|
||||||
projectId,
|
|
||||||
version: CLI_VERSION,
|
|
||||||
}),
|
|
||||||
{ exitOnCtrlC: false },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── sessions ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const sessionsCmd = program.command('sessions').description('Manage active agent sessions');
|
|
||||||
|
|
||||||
sessionsCmd
|
|
||||||
.command('list')
|
|
||||||
.description('List active agent sessions')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
|
||||||
.action(async (opts: { gateway: string }) => {
|
|
||||||
const { withAuth } = await import('./commands/with-auth.js');
|
|
||||||
const auth = await withAuth(opts.gateway);
|
|
||||||
const { fetchSessions } = await import('./tui/gateway-api.js');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await fetchSessions(auth.gateway, auth.cookie);
|
|
||||||
if (result.total === 0) {
|
|
||||||
console.log('No active sessions.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`Active sessions (${result.total}):\n`);
|
|
||||||
for (const s of result.sessions) {
|
|
||||||
const created = new Date(s.createdAt).toLocaleString();
|
|
||||||
const durationSec = Math.round(s.durationMs / 1000);
|
|
||||||
console.log(` ID: ${s.id}`);
|
|
||||||
console.log(` Model: ${s.provider}/${s.modelId}`);
|
|
||||||
console.log(` Created: ${created}`);
|
|
||||||
console.log(` Prompts: ${s.promptCount}`);
|
|
||||||
console.log(` Duration: ${durationSec}s`);
|
|
||||||
if (s.channels.length > 0) {
|
|
||||||
console.log(` Channels: ${s.channels.join(', ')}`);
|
|
||||||
}
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
sessionsCmd
|
|
||||||
.command('resume <id>')
|
|
||||||
.description('Resume an existing agent session in the TUI')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
|
||||||
.action(async (id: string, opts: { gateway: string }) => {
|
|
||||||
const { loadSession, validateSession } = await import('./auth.js');
|
|
||||||
|
|
||||||
const session = loadSession(opts.gateway);
|
|
||||||
if (!session) {
|
|
||||||
console.error('Not signed in. Run `mosaic login` first.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = await validateSession(opts.gateway, session.cookie);
|
|
||||||
if (!valid) {
|
|
||||||
console.error('Session expired. Run `mosaic login` again.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { render } = await import('ink');
|
|
||||||
const React = await import('react');
|
|
||||||
const { TuiApp } = await import('./tui/app.js');
|
|
||||||
|
|
||||||
render(
|
|
||||||
React.createElement(TuiApp, {
|
|
||||||
gatewayUrl: opts.gateway,
|
|
||||||
conversationId: id,
|
|
||||||
sessionCookie: session.cookie,
|
|
||||||
version: CLI_VERSION,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
sessionsCmd
|
|
||||||
.command('destroy <id>')
|
|
||||||
.description('Terminate an active agent session')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
|
||||||
.action(async (id: string, opts: { gateway: string }) => {
|
|
||||||
const { withAuth } = await import('./commands/with-auth.js');
|
|
||||||
const auth = await withAuth(opts.gateway);
|
|
||||||
const { deleteSession } = await import('./tui/gateway-api.js');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteSession(auth.gateway, auth.cookie, id);
|
|
||||||
console.log(`Session ${id} destroyed.`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── gateway ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
registerGatewayCommand(program);
|
|
||||||
|
|
||||||
// ─── agent ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
registerAgentCommand(program);
|
|
||||||
|
|
||||||
// ─── mission ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
registerMissionCommand(program);
|
|
||||||
|
|
||||||
// ─── quality-rails ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
registerQualityRails(program);
|
|
||||||
|
|
||||||
// ─── 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 { checkForAllUpdates, formatAllPackagesTable, getInstallAllCommand } =
|
|
||||||
await import('@mosaicstack/mosaic');
|
|
||||||
const { execSync } = await import('node:child_process');
|
|
||||||
|
|
||||||
console.log('Checking for updates…');
|
|
||||||
const results = checkForAllUpdates({ skipCache: true });
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
console.log(formatAllPackagesTable(results));
|
|
||||||
|
|
||||||
const outdated = results.filter((r: { updateAvailable: boolean }) => r.updateAvailable);
|
|
||||||
if (outdated.length === 0) {
|
|
||||||
const anyInstalled = results.some((r: { current: string }) => r.current);
|
|
||||||
if (!anyInstalled) {
|
|
||||||
console.error('No @mosaicstack/* packages are installed.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('\n✔ All packages up to date.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.check) {
|
|
||||||
process.exit(2); // Signal to callers that an update exists
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nInstalling ${outdated.length} update(s)…`);
|
|
||||||
try {
|
|
||||||
// Relies on @mosaicstack:registry in ~/.npmrc
|
|
||||||
const cmd = getInstallAllCommand(outdated);
|
|
||||||
execSync(cmd, {
|
|
||||||
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 ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
program
|
|
||||||
.command('wizard')
|
|
||||||
.description('Run the Mosaic installation wizard')
|
|
||||||
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
|
|
||||||
.option('--source-dir <path>', 'Source directory for framework files')
|
|
||||||
.option('--mosaic-home <path>', 'Target config directory')
|
|
||||||
.option('--name <name>', 'Agent name')
|
|
||||||
.option('--role <description>', 'Agent role description')
|
|
||||||
.option('--style <style>', 'Communication style: direct|friendly|formal')
|
|
||||||
.option('--accessibility <prefs>', 'Accessibility preferences')
|
|
||||||
.option('--guardrails <rules>', 'Custom guardrails')
|
|
||||||
.option('--user-name <name>', 'Your name')
|
|
||||||
.option('--pronouns <pronouns>', 'Your pronouns')
|
|
||||||
.option('--timezone <tz>', 'Your timezone')
|
|
||||||
.action(async (opts: Record<string, string | boolean | undefined>) => {
|
|
||||||
const {
|
|
||||||
runWizard,
|
|
||||||
ClackPrompter,
|
|
||||||
HeadlessPrompter,
|
|
||||||
createConfigService,
|
|
||||||
WizardCancelledError,
|
|
||||||
DEFAULT_MOSAIC_HOME,
|
|
||||||
} = await import('@mosaicstack/mosaic');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mosaicHome = (opts['mosaicHome'] as string | undefined) ?? DEFAULT_MOSAIC_HOME;
|
|
||||||
const sourceDir = (opts['sourceDir'] as string | undefined) ?? mosaicHome;
|
|
||||||
|
|
||||||
const prompter = opts['nonInteractive'] ? new HeadlessPrompter() : new ClackPrompter();
|
|
||||||
|
|
||||||
const configService = createConfigService(mosaicHome, sourceDir);
|
|
||||||
|
|
||||||
await runWizard({
|
|
||||||
mosaicHome,
|
|
||||||
sourceDir,
|
|
||||||
prompter,
|
|
||||||
configService,
|
|
||||||
cliOverrides: {
|
|
||||||
soul: {
|
|
||||||
agentName: opts['name'] as string | undefined,
|
|
||||||
roleDescription: opts['role'] as string | undefined,
|
|
||||||
communicationStyle: opts['style'] as 'direct' | 'friendly' | 'formal' | undefined,
|
|
||||||
accessibility: opts['accessibility'] as string | undefined,
|
|
||||||
customGuardrails: opts['guardrails'] as string | undefined,
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
userName: opts['userName'] as string | undefined,
|
|
||||||
pronouns: opts['pronouns'] as string | undefined,
|
|
||||||
timezone: opts['timezone'] as string | undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof WizardCancelledError) {
|
|
||||||
console.log('\nWizard cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
console.error('Wizard failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
program.parse();
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
import type { Command } from 'commander';
|
|
||||||
import { withAuth } from './with-auth.js';
|
|
||||||
import { selectItem } from './select-dialog.js';
|
|
||||||
import {
|
|
||||||
fetchAgentConfigs,
|
|
||||||
createAgentConfig,
|
|
||||||
updateAgentConfig,
|
|
||||||
deleteAgentConfig,
|
|
||||||
fetchProjects,
|
|
||||||
fetchProviders,
|
|
||||||
} from '../tui/gateway-api.js';
|
|
||||||
import type { AgentConfigInfo } from '../tui/gateway-api.js';
|
|
||||||
|
|
||||||
function formatAgent(a: AgentConfigInfo): string {
|
|
||||||
const sys = a.isSystem ? ' [system]' : '';
|
|
||||||
return `${a.name}${sys} — ${a.provider}/${a.model} (${a.status})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAgentDetail(a: AgentConfigInfo) {
|
|
||||||
console.log(` ID: ${a.id}`);
|
|
||||||
console.log(` Name: ${a.name}`);
|
|
||||||
console.log(` Provider: ${a.provider}`);
|
|
||||||
console.log(` Model: ${a.model}`);
|
|
||||||
console.log(` Status: ${a.status}`);
|
|
||||||
console.log(` System: ${a.isSystem ? 'yes' : 'no'}`);
|
|
||||||
console.log(` Project: ${a.projectId ?? '—'}`);
|
|
||||||
console.log(` System Prompt: ${a.systemPrompt ? `${a.systemPrompt.slice(0, 80)}...` : '—'}`);
|
|
||||||
console.log(` Tools: ${a.allowedTools ? a.allowedTools.join(', ') : 'all'}`);
|
|
||||||
console.log(` Skills: ${a.skills ? a.skills.join(', ') : '—'}`);
|
|
||||||
console.log(` Created: ${new Date(a.createdAt).toLocaleString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerAgentCommand(program: Command) {
|
|
||||||
const cmd = program
|
|
||||||
.command('agent')
|
|
||||||
.description('Manage agent configurations')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
|
||||||
.option('--list', 'List all agents')
|
|
||||||
.option('--new', 'Create a new agent')
|
|
||||||
.option('--show <idOrName>', 'Show agent details')
|
|
||||||
.option('--update <idOrName>', 'Update an agent')
|
|
||||||
.option('--delete <idOrName>', 'Delete an agent')
|
|
||||||
.action(
|
|
||||||
async (opts: {
|
|
||||||
gateway: string;
|
|
||||||
list?: boolean;
|
|
||||||
new?: boolean;
|
|
||||||
show?: string;
|
|
||||||
update?: string;
|
|
||||||
delete?: string;
|
|
||||||
}) => {
|
|
||||||
const auth = await withAuth(opts.gateway);
|
|
||||||
|
|
||||||
if (opts.list) {
|
|
||||||
return listAgents(auth.gateway, auth.cookie);
|
|
||||||
}
|
|
||||||
if (opts.new) {
|
|
||||||
return createAgentWizard(auth.gateway, auth.cookie);
|
|
||||||
}
|
|
||||||
if (opts.show) {
|
|
||||||
return showAgent(auth.gateway, auth.cookie, opts.show);
|
|
||||||
}
|
|
||||||
if (opts.update) {
|
|
||||||
return updateAgentWizard(auth.gateway, auth.cookie, opts.update);
|
|
||||||
}
|
|
||||||
if (opts.delete) {
|
|
||||||
return deleteAgent(auth.gateway, auth.cookie, opts.delete);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: interactive select
|
|
||||||
return interactiveSelect(auth.gateway, auth.cookie);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return cmd;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveAgent(
|
|
||||||
gateway: string,
|
|
||||||
cookie: string,
|
|
||||||
idOrName: string,
|
|
||||||
): Promise<AgentConfigInfo | undefined> {
|
|
||||||
const agents = await fetchAgentConfigs(gateway, cookie);
|
|
||||||
return agents.find((a) => a.id === idOrName || a.name === idOrName);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listAgents(gateway: string, cookie: string) {
|
|
||||||
const agents = await fetchAgentConfigs(gateway, cookie);
|
|
||||||
if (agents.length === 0) {
|
|
||||||
console.log('No agents found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`Agents (${agents.length}):\n`);
|
|
||||||
for (const a of agents) {
|
|
||||||
const sys = a.isSystem ? ' [system]' : '';
|
|
||||||
const project = a.projectId ? ` project=${a.projectId.slice(0, 8)}` : '';
|
|
||||||
console.log(` ${a.name}${sys} ${a.provider}/${a.model} ${a.status}${project}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showAgent(gateway: string, cookie: string, idOrName: string) {
|
|
||||||
const agent = await resolveAgent(gateway, cookie, idOrName);
|
|
||||||
if (!agent) {
|
|
||||||
console.error(`Agent "${idOrName}" not found.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
showAgentDetail(agent);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function interactiveSelect(gateway: string, cookie: string) {
|
|
||||||
const agents = await fetchAgentConfigs(gateway, cookie);
|
|
||||||
const selected = await selectItem(agents, {
|
|
||||||
message: 'Select an agent:',
|
|
||||||
render: formatAgent,
|
|
||||||
emptyMessage: 'No agents found. Create one with `mosaic agent --new`.',
|
|
||||||
});
|
|
||||||
if (selected) {
|
|
||||||
showAgentDetail(selected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createAgentWizard(gateway: string, cookie: string) {
|
|
||||||
const readline = await import('node:readline');
|
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const name = await ask('Agent name: ');
|
|
||||||
if (!name.trim()) {
|
|
||||||
console.error('Name is required.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Project selection
|
|
||||||
const projects = await fetchProjects(gateway, cookie);
|
|
||||||
let projectId: string | undefined;
|
|
||||||
if (projects.length > 0) {
|
|
||||||
const selected = await selectItem(projects, {
|
|
||||||
message: 'Assign to project (optional):',
|
|
||||||
render: (p) => `${p.name} (${p.status})`,
|
|
||||||
});
|
|
||||||
if (selected) projectId = selected.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider / model selection
|
|
||||||
const providers = await fetchProviders(gateway, cookie);
|
|
||||||
let provider = 'default';
|
|
||||||
let model = 'default';
|
|
||||||
|
|
||||||
if (providers.length > 0) {
|
|
||||||
const allModels = providers.flatMap((p) =>
|
|
||||||
p.models.map((m) => ({ provider: p.name, model: m.id, label: `${p.name}/${m.id}` })),
|
|
||||||
);
|
|
||||||
if (allModels.length > 0) {
|
|
||||||
const selected = await selectItem(allModels, {
|
|
||||||
message: 'Select model:',
|
|
||||||
render: (m) => m.label,
|
|
||||||
});
|
|
||||||
if (selected) {
|
|
||||||
provider = selected.provider;
|
|
||||||
model = selected.model;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemPrompt = await ask('System prompt (optional, press Enter to skip): ');
|
|
||||||
|
|
||||||
const agent = await createAgentConfig(gateway, cookie, {
|
|
||||||
name: name.trim(),
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
projectId,
|
|
||||||
systemPrompt: systemPrompt.trim() || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\nAgent "${agent.name}" created (${agent.id}).`);
|
|
||||||
} finally {
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateAgentWizard(gateway: string, cookie: string, idOrName: string) {
|
|
||||||
const agent = await resolveAgent(gateway, cookie, idOrName);
|
|
||||||
if (!agent) {
|
|
||||||
console.error(`Agent "${idOrName}" not found.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const readline = await import('node:readline');
|
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`Updating agent: ${agent.name}\n`);
|
|
||||||
|
|
||||||
const name = await ask(`Name [${agent.name}]: `);
|
|
||||||
const systemPrompt = await ask(`System prompt [${agent.systemPrompt ? 'set' : 'none'}]: `);
|
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {};
|
|
||||||
if (name.trim()) updates['name'] = name.trim();
|
|
||||||
if (systemPrompt.trim()) updates['systemPrompt'] = systemPrompt.trim();
|
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
|
||||||
console.log('No changes.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await updateAgentConfig(gateway, cookie, agent.id, updates);
|
|
||||||
console.log(`\nAgent "${updated.name}" updated.`);
|
|
||||||
} finally {
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAgent(gateway: string, cookie: string, idOrName: string) {
|
|
||||||
const agent = await resolveAgent(gateway, cookie, idOrName);
|
|
||||||
if (!agent) {
|
|
||||||
console.error(`Agent "${idOrName}" not found.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agent.isSystem) {
|
|
||||||
console.error('Cannot delete system agents.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const readline = await import('node:readline');
|
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
const answer = await new Promise<string>((resolve) =>
|
|
||||||
rl.question(`Delete agent "${agent.name}"? (y/N): `, resolve),
|
|
||||||
);
|
|
||||||
rl.close();
|
|
||||||
|
|
||||||
if (answer.toLowerCase() !== 'y') {
|
|
||||||
console.log('Cancelled.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await deleteAgentConfig(gateway, cookie, agent.id);
|
|
||||||
console.log(`Agent "${agent.name}" deleted.`);
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import type { Command } from 'commander';
|
|
||||||
import {
|
|
||||||
getDaemonPid,
|
|
||||||
readMeta,
|
|
||||||
startDaemon,
|
|
||||||
stopDaemon,
|
|
||||||
waitForHealth,
|
|
||||||
} from './gateway/daemon.js';
|
|
||||||
|
|
||||||
interface GatewayParentOpts {
|
|
||||||
host: string;
|
|
||||||
port: string;
|
|
||||||
token?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveOpts(raw: GatewayParentOpts): { host: string; port: number; token?: string } {
|
|
||||||
const meta = readMeta();
|
|
||||||
return {
|
|
||||||
host: raw.host ?? meta?.host ?? 'localhost',
|
|
||||||
port: parseInt(raw.port, 10) || meta?.port || 14242,
|
|
||||||
token: raw.token ?? meta?.adminToken,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerGatewayCommand(program: Command): void {
|
|
||||||
const gw = program
|
|
||||||
.command('gateway')
|
|
||||||
.description('Manage the Mosaic gateway daemon')
|
|
||||||
.helpOption('--help', 'Display help')
|
|
||||||
.option('-h, --host <host>', 'Gateway host', 'localhost')
|
|
||||||
.option('-p, --port <port>', 'Gateway port', '14242')
|
|
||||||
.option('-t, --token <token>', 'Admin API token')
|
|
||||||
.action(() => {
|
|
||||||
gw.outputHelp();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── install ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
gw.command('install')
|
|
||||||
.description('Install and configure the gateway daemon')
|
|
||||||
.option('--skip-install', 'Skip npm package installation (use local build)')
|
|
||||||
.action(async (cmdOpts: { skipInstall?: boolean }) => {
|
|
||||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
|
||||||
const { runInstall } = await import('./gateway/install.js');
|
|
||||||
await runInstall({ ...opts, skipInstall: cmdOpts.skipInstall });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── start ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
gw.command('start')
|
|
||||||
.description('Start the gateway daemon')
|
|
||||||
.action(async () => {
|
|
||||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
|
||||||
try {
|
|
||||||
const pid = startDaemon();
|
|
||||||
console.log(`Gateway started (PID ${pid.toString()})`);
|
|
||||||
console.log('Waiting for health...');
|
|
||||||
const healthy = await waitForHealth(opts.host, opts.port);
|
|
||||||
if (healthy) {
|
|
||||||
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
|
|
||||||
} else {
|
|
||||||
console.warn('Gateway started but health check timed out. Check logs.');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── stop ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
gw.command('stop')
|
|
||||||
.description('Stop the gateway daemon')
|
|
||||||
.action(async () => {
|
|
||||||
try {
|
|
||||||
await stopDaemon();
|
|
||||||
console.log('Gateway stopped.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── restart ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
gw.command('restart')
|
|
||||||
.description('Restart the gateway daemon')
|
|
||||||
.action(async () => {
|
|
||||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
|
||||||
const pid = getDaemonPid();
|
|
||||||
if (pid !== null) {
|
|
||||||
console.log('Stopping gateway...');
|
|
||||||
await stopDaemon();
|
|
||||||
}
|
|
||||||
console.log('Starting gateway...');
|
|
||||||
try {
|
|
||||||
const newPid = startDaemon();
|
|
||||||
console.log(`Gateway started (PID ${newPid.toString()})`);
|
|
||||||
const healthy = await waitForHealth(opts.host, opts.port);
|
|
||||||
if (healthy) {
|
|
||||||
console.log(`Gateway ready at http://${opts.host}:${opts.port.toString()}`);
|
|
||||||
} else {
|
|
||||||
console.warn('Gateway started but health check timed out. Check logs.');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err instanceof Error ? err.message : String(err));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── status ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
gw.command('status')
|
|
||||||
.description('Show gateway daemon status and health')
|
|
||||||
.action(async () => {
|
|
||||||
const opts = resolveOpts(gw.opts() as GatewayParentOpts);
|
|
||||||
const { runStatus } = await import('./gateway/status.js');
|
|
||||||
await runStatus(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── config ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
gw.command('config')
|
|
||||||
.description('View or modify gateway configuration')
|
|
||||||
.option('--set <KEY=VALUE>', 'Set a configuration value')
|
|
||||||
.option('--unset <KEY>', 'Remove a configuration key')
|
|
||||||
.option('--edit', 'Open config in $EDITOR')
|
|
||||||
.action(async (cmdOpts: { set?: string; unset?: string; edit?: boolean }) => {
|
|
||||||
const { runConfig } = await import('./gateway/config.js');
|
|
||||||
await runConfig(cmdOpts);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── logs ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
gw.command('logs')
|
|
||||||
.description('View gateway daemon logs')
|
|
||||||
.option('-f, --follow', 'Follow log output')
|
|
||||||
.option('-n, --lines <count>', 'Number of lines to show', '50')
|
|
||||||
.action(async (cmdOpts: { follow?: boolean; lines?: string }) => {
|
|
||||||
const { runLogs } = await import('./gateway/logs.js');
|
|
||||||
runLogs({ follow: cmdOpts.follow, lines: parseInt(cmdOpts.lines ?? '50', 10) });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── uninstall ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
gw.command('uninstall')
|
|
||||||
.description('Uninstall the gateway daemon and optionally remove data')
|
|
||||||
.action(async () => {
|
|
||||||
const { runUninstall } = await import('./gateway/uninstall.js');
|
|
||||||
await runUninstall();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
||||||
import { execSync } from 'node:child_process';
|
|
||||||
import { ENV_FILE, getDaemonPid, readMeta, META_FILE, ensureDirs } from './daemon.js';
|
|
||||||
|
|
||||||
// Keys that should be masked in output
|
|
||||||
const SECRET_KEYS = new Set([
|
|
||||||
'BETTER_AUTH_SECRET',
|
|
||||||
'ANTHROPIC_API_KEY',
|
|
||||||
'OPENAI_API_KEY',
|
|
||||||
'ZAI_API_KEY',
|
|
||||||
'OPENROUTER_API_KEY',
|
|
||||||
'DISCORD_BOT_TOKEN',
|
|
||||||
'TELEGRAM_BOT_TOKEN',
|
|
||||||
]);
|
|
||||||
|
|
||||||
function maskValue(key: string, value: string): string {
|
|
||||||
if (SECRET_KEYS.has(key) && value.length > 8) {
|
|
||||||
return value.slice(0, 4) + '…' + value.slice(-4);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEnvFile(): Map<string, string> {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
if (!existsSync(ENV_FILE)) return map;
|
|
||||||
|
|
||||||
const lines = readFileSync(ENV_FILE, 'utf-8').split('\n');
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
||||||
const eqIdx = trimmed.indexOf('=');
|
|
||||||
if (eqIdx === -1) continue;
|
|
||||||
map.set(trimmed.slice(0, eqIdx), trimmed.slice(eqIdx + 1));
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeEnvFile(entries: Map<string, string>): void {
|
|
||||||
ensureDirs();
|
|
||||||
const lines: string[] = [];
|
|
||||||
for (const [key, value] of entries) {
|
|
||||||
lines.push(`${key}=${value}`);
|
|
||||||
}
|
|
||||||
writeFileSync(ENV_FILE, lines.join('\n') + '\n', { mode: 0o600 });
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfigOpts {
|
|
||||||
set?: string;
|
|
||||||
unset?: string;
|
|
||||||
edit?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runConfig(opts: ConfigOpts): Promise<void> {
|
|
||||||
// Set a value
|
|
||||||
if (opts.set) {
|
|
||||||
const eqIdx = opts.set.indexOf('=');
|
|
||||||
if (eqIdx === -1) {
|
|
||||||
console.error('Usage: mosaic gateway config --set KEY=VALUE');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const key = opts.set.slice(0, eqIdx);
|
|
||||||
const value = opts.set.slice(eqIdx + 1);
|
|
||||||
const entries = parseEnvFile();
|
|
||||||
entries.set(key, value);
|
|
||||||
writeEnvFile(entries);
|
|
||||||
console.log(`Set ${key}=${maskValue(key, value)}`);
|
|
||||||
promptRestart();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unset a value
|
|
||||||
if (opts.unset) {
|
|
||||||
const entries = parseEnvFile();
|
|
||||||
if (!entries.has(opts.unset)) {
|
|
||||||
console.error(`Key not found: ${opts.unset}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
entries.delete(opts.unset);
|
|
||||||
writeEnvFile(entries);
|
|
||||||
console.log(`Removed ${opts.unset}`);
|
|
||||||
promptRestart();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open in editor
|
|
||||||
if (opts.edit) {
|
|
||||||
if (!existsSync(ENV_FILE)) {
|
|
||||||
console.error(`No config file found at ${ENV_FILE}`);
|
|
||||||
console.error('Run `mosaic gateway install` first.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const editor = process.env['EDITOR'] ?? process.env['VISUAL'] ?? 'vi';
|
|
||||||
try {
|
|
||||||
execSync(`${editor} "${ENV_FILE}"`, { stdio: 'inherit' });
|
|
||||||
promptRestart();
|
|
||||||
} catch {
|
|
||||||
console.error('Editor exited with error.');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: show current config
|
|
||||||
showConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showConfig(): void {
|
|
||||||
if (!existsSync(ENV_FILE)) {
|
|
||||||
console.log('No gateway configuration found.');
|
|
||||||
console.log('Run `mosaic gateway install` to set up.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = parseEnvFile();
|
|
||||||
const meta = readMeta();
|
|
||||||
|
|
||||||
console.log('Mosaic Gateway Configuration');
|
|
||||||
console.log('────────────────────────────');
|
|
||||||
console.log(` Config file: ${ENV_FILE}`);
|
|
||||||
console.log(` Meta file: ${META_FILE}`);
|
|
||||||
console.log();
|
|
||||||
|
|
||||||
if (entries.size === 0) {
|
|
||||||
console.log(' (empty)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxKeyLen = Math.max(...[...entries.keys()].map((k) => k.length));
|
|
||||||
for (const [key, value] of entries) {
|
|
||||||
const padding = ' '.repeat(maxKeyLen - key.length);
|
|
||||||
console.log(` ${key}${padding} ${maskValue(key, value)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (meta?.adminToken) {
|
|
||||||
console.log();
|
|
||||||
console.log(` Admin token: ${maskValue('token', meta.adminToken)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function promptRestart(): void {
|
|
||||||
if (getDaemonPid() !== null) {
|
|
||||||
console.log('\nGateway is running — restart to apply changes: mosaic gateway restart');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
import { spawn, execSync } from 'node:child_process';
|
|
||||||
import {
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
writeFileSync,
|
|
||||||
unlinkSync,
|
|
||||||
openSync,
|
|
||||||
constants,
|
|
||||||
} from 'node:fs';
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
|
|
||||||
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const GATEWAY_HOME = resolve(
|
|
||||||
process.env['MOSAIC_GATEWAY_HOME'] ?? join(homedir(), '.config', 'mosaic', 'gateway'),
|
|
||||||
);
|
|
||||||
export const PID_FILE = join(GATEWAY_HOME, 'daemon.pid');
|
|
||||||
export const LOG_DIR = join(GATEWAY_HOME, 'logs');
|
|
||||||
export const LOG_FILE = join(LOG_DIR, 'gateway.log');
|
|
||||||
export const ENV_FILE = join(GATEWAY_HOME, '.env');
|
|
||||||
export const META_FILE = join(GATEWAY_HOME, 'meta.json');
|
|
||||||
|
|
||||||
// ─── Meta ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface GatewayMeta {
|
|
||||||
version: string;
|
|
||||||
installedAt: string;
|
|
||||||
entryPoint: string;
|
|
||||||
adminToken?: string;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readMeta(): GatewayMeta | null {
|
|
||||||
if (!existsSync(META_FILE)) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(readFileSync(META_FILE, 'utf-8')) as GatewayMeta;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeMeta(meta: GatewayMeta): void {
|
|
||||||
ensureDirs();
|
|
||||||
writeFileSync(META_FILE, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Directories ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function ensureDirs(): void {
|
|
||||||
mkdirSync(GATEWAY_HOME, { recursive: true, mode: 0o700 });
|
|
||||||
mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── PID management ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function readPid(): number | null {
|
|
||||||
if (!existsSync(PID_FILE)) return null;
|
|
||||||
try {
|
|
||||||
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
|
|
||||||
return Number.isNaN(pid) ? null : pid;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRunning(pid: number): boolean {
|
|
||||||
try {
|
|
||||||
process.kill(pid, 0);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDaemonPid(): number | null {
|
|
||||||
const pid = readPid();
|
|
||||||
if (pid === null) return null;
|
|
||||||
return isRunning(pid) ? pid : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Entry point resolution ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function resolveGatewayEntry(): string {
|
|
||||||
// Check meta.json for custom entry point
|
|
||||||
const meta = readMeta();
|
|
||||||
if (meta?.entryPoint && existsSync(meta.entryPoint)) {
|
|
||||||
return meta.entryPoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to resolve from globally installed @mosaicstack/gateway
|
|
||||||
try {
|
|
||||||
const req = createRequire(import.meta.url);
|
|
||||||
const pkgPath = req.resolve('@mosaicstack/gateway/package.json');
|
|
||||||
const mainEntry = join(resolve(pkgPath, '..'), 'dist', 'main.js');
|
|
||||||
if (existsSync(mainEntry)) return mainEntry;
|
|
||||||
} catch {
|
|
||||||
// Not installed globally
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Cannot find gateway entry point. Run `mosaic gateway install` first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Start / Stop / Health ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function startDaemon(): number {
|
|
||||||
const running = getDaemonPid();
|
|
||||||
if (running !== null) {
|
|
||||||
throw new Error(`Gateway is already running (PID ${running.toString()})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureDirs();
|
|
||||||
const entryPoint = resolveGatewayEntry();
|
|
||||||
|
|
||||||
// Load env vars from gateway .env
|
|
||||||
const env: Record<string, string> = { ...process.env } as Record<string, string>;
|
|
||||||
if (existsSync(ENV_FILE)) {
|
|
||||||
for (const line of readFileSync(ENV_FILE, 'utf-8').split('\n')) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
||||||
const eqIdx = trimmed.indexOf('=');
|
|
||||||
if (eqIdx > 0) env[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const logFd = openSync(LOG_FILE, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND);
|
|
||||||
|
|
||||||
const child = spawn('node', [entryPoint], {
|
|
||||||
detached: true,
|
|
||||||
stdio: ['ignore', logFd, logFd],
|
|
||||||
env,
|
|
||||||
cwd: GATEWAY_HOME,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!child.pid) {
|
|
||||||
throw new Error('Failed to spawn gateway process');
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(PID_FILE, child.pid.toString(), { mode: 0o600 });
|
|
||||||
child.unref();
|
|
||||||
|
|
||||||
return child.pid;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stopDaemon(timeoutMs = 10_000): Promise<void> {
|
|
||||||
const pid = getDaemonPid();
|
|
||||||
if (pid === null) {
|
|
||||||
throw new Error('Gateway is not running');
|
|
||||||
}
|
|
||||||
|
|
||||||
process.kill(pid, 'SIGTERM');
|
|
||||||
|
|
||||||
// Poll for exit
|
|
||||||
const start = Date.now();
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
|
||||||
if (!isRunning(pid)) {
|
|
||||||
cleanPidFile();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await sleep(250);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force kill
|
|
||||||
try {
|
|
||||||
process.kill(pid, 'SIGKILL');
|
|
||||||
} catch {
|
|
||||||
// Already dead
|
|
||||||
}
|
|
||||||
cleanPidFile();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanPidFile(): void {
|
|
||||||
try {
|
|
||||||
unlinkSync(PID_FILE);
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForHealth(
|
|
||||||
host: string,
|
|
||||||
port: number,
|
|
||||||
timeoutMs = 30_000,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const start = Date.now();
|
|
||||||
let delay = 500;
|
|
||||||
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`http://${host}:${port.toString()}/health`);
|
|
||||||
if (res.ok) return true;
|
|
||||||
} catch {
|
|
||||||
// Not ready yet
|
|
||||||
}
|
|
||||||
await sleep(delay);
|
|
||||||
delay = Math.min(delay * 1.5, 3000);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── npm install helper ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const GITEA_REGISTRY = 'https://git.mosaicstack.dev/api/packages/mosaic/npm/';
|
|
||||||
|
|
||||||
export function installGatewayPackage(): void {
|
|
||||||
console.log('Installing @mosaicstack/gateway from Gitea registry...');
|
|
||||||
execSync(`npm install -g @mosaicstack/gateway@latest --@mosaic:registry=${GITEA_REGISTRY}`, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
timeout: 120_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function uninstallGatewayPackage(): void {
|
|
||||||
try {
|
|
||||||
execSync('npm uninstall -g @mosaicstack/gateway', {
|
|
||||||
stdio: 'inherit',
|
|
||||||
timeout: 60_000,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
console.warn('Warning: npm uninstall may not have completed cleanly.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getInstalledGatewayVersion(): string | null {
|
|
||||||
try {
|
|
||||||
const output = execSync('npm ls -g @mosaicstack/gateway --json --depth=0', {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: 15_000,
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
const data = JSON.parse(output) as {
|
|
||||||
dependencies?: { '@mosaicstack/gateway'?: { version?: string } };
|
|
||||||
};
|
|
||||||
return data.dependencies?.['@mosaicstack/gateway']?.version ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
import { randomBytes } from 'node:crypto';
|
|
||||||
import { writeFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { createInterface } from 'node:readline';
|
|
||||||
import type { GatewayMeta } from './daemon.js';
|
|
||||||
import {
|
|
||||||
ENV_FILE,
|
|
||||||
GATEWAY_HOME,
|
|
||||||
ensureDirs,
|
|
||||||
installGatewayPackage,
|
|
||||||
readMeta,
|
|
||||||
resolveGatewayEntry,
|
|
||||||
startDaemon,
|
|
||||||
waitForHealth,
|
|
||||||
writeMeta,
|
|
||||||
getInstalledGatewayVersion,
|
|
||||||
} from './daemon.js';
|
|
||||||
|
|
||||||
interface InstallOpts {
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
skipInstall?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
|
||||||
return new Promise((resolve) => rl.question(question, resolve));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runInstall(opts: InstallOpts): Promise<void> {
|
|
||||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
try {
|
|
||||||
await doInstall(rl, opts);
|
|
||||||
} finally {
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doInstall(rl: ReturnType<typeof createInterface>, opts: InstallOpts): Promise<void> {
|
|
||||||
// Check existing installation
|
|
||||||
const existing = readMeta();
|
|
||||||
if (existing) {
|
|
||||||
const answer = await prompt(
|
|
||||||
rl,
|
|
||||||
`Gateway already installed (v${existing.version}). Reinstall? [y/N] `,
|
|
||||||
);
|
|
||||||
if (answer.toLowerCase() !== 'y') {
|
|
||||||
console.log('Aborted.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Install npm package
|
|
||||||
if (!opts.skipInstall) {
|
|
||||||
installGatewayPackage();
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureDirs();
|
|
||||||
|
|
||||||
// Step 2: Collect configuration
|
|
||||||
console.log('\n─── Gateway Configuration ───\n');
|
|
||||||
|
|
||||||
// Tier selection
|
|
||||||
console.log('Storage tier:');
|
|
||||||
console.log(' 1. Local (embedded database, no dependencies)');
|
|
||||||
console.log(' 2. Team (PostgreSQL + Valkey required)');
|
|
||||||
const tierAnswer = (await prompt(rl, 'Select [1]: ')).trim() || '1';
|
|
||||||
const tier = tierAnswer === '2' ? 'team' : 'local';
|
|
||||||
|
|
||||||
const port =
|
|
||||||
opts.port !== 14242
|
|
||||||
? opts.port
|
|
||||||
: parseInt(
|
|
||||||
(await prompt(rl, `Gateway port [${opts.port.toString()}]: `)) || opts.port.toString(),
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
let databaseUrl: string | undefined;
|
|
||||||
let valkeyUrl: string | undefined;
|
|
||||||
|
|
||||||
if (tier === 'team') {
|
|
||||||
databaseUrl =
|
|
||||||
(await prompt(rl, 'DATABASE_URL [postgresql://mosaic:mosaic@localhost:5433/mosaic]: ')) ||
|
|
||||||
'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
|
||||||
|
|
||||||
valkeyUrl =
|
|
||||||
(await prompt(rl, 'VALKEY_URL [redis://localhost:6380]: ')) || 'redis://localhost:6380';
|
|
||||||
}
|
|
||||||
|
|
||||||
const anthropicKey = await prompt(rl, 'ANTHROPIC_API_KEY (optional, press Enter to skip): ');
|
|
||||||
|
|
||||||
const corsOrigin =
|
|
||||||
(await prompt(rl, 'CORS origin [http://localhost:3000]: ')) || 'http://localhost:3000';
|
|
||||||
|
|
||||||
// Generate auth secret
|
|
||||||
const authSecret = randomBytes(32).toString('hex');
|
|
||||||
|
|
||||||
// Step 3: Write .env
|
|
||||||
const envLines = [
|
|
||||||
`GATEWAY_PORT=${port.toString()}`,
|
|
||||||
`BETTER_AUTH_SECRET=${authSecret}`,
|
|
||||||
`BETTER_AUTH_URL=http://${opts.host}:${port.toString()}`,
|
|
||||||
`GATEWAY_CORS_ORIGIN=${corsOrigin}`,
|
|
||||||
`OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318`,
|
|
||||||
`OTEL_SERVICE_NAME=mosaic-gateway`,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (tier === 'team' && databaseUrl && valkeyUrl) {
|
|
||||||
envLines.push(`DATABASE_URL=${databaseUrl}`);
|
|
||||||
envLines.push(`VALKEY_URL=${valkeyUrl}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anthropicKey) {
|
|
||||||
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(ENV_FILE, envLines.join('\n') + '\n', { mode: 0o600 });
|
|
||||||
console.log(`\nConfig written to ${ENV_FILE}`);
|
|
||||||
|
|
||||||
// Step 3b: Write mosaic.config.json
|
|
||||||
const mosaicConfig =
|
|
||||||
tier === 'local'
|
|
||||||
? {
|
|
||||||
tier: 'local',
|
|
||||||
storage: { type: 'pglite', dataDir: join(GATEWAY_HOME, 'storage-pglite') },
|
|
||||||
queue: { type: 'local', dataDir: join(GATEWAY_HOME, 'queue') },
|
|
||||||
memory: { type: 'keyword' },
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
tier: 'team',
|
|
||||||
storage: { type: 'postgres', url: databaseUrl },
|
|
||||||
queue: { type: 'bullmq', url: valkeyUrl },
|
|
||||||
memory: { type: 'pgvector' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const configFile = join(GATEWAY_HOME, 'mosaic.config.json');
|
|
||||||
writeFileSync(configFile, JSON.stringify(mosaicConfig, null, 2) + '\n', { mode: 0o600 });
|
|
||||||
console.log(`Config written to ${configFile}`);
|
|
||||||
|
|
||||||
// Step 4: Write meta.json
|
|
||||||
let entryPoint: string;
|
|
||||||
try {
|
|
||||||
entryPoint = resolveGatewayEntry();
|
|
||||||
} catch {
|
|
||||||
console.error('Error: Gateway package not found after install.');
|
|
||||||
console.error('Check that @mosaicstack/gateway installed correctly.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const version = getInstalledGatewayVersion() ?? 'unknown';
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
version,
|
|
||||||
installedAt: new Date().toISOString(),
|
|
||||||
entryPoint,
|
|
||||||
host: opts.host,
|
|
||||||
port,
|
|
||||||
};
|
|
||||||
writeMeta(meta);
|
|
||||||
|
|
||||||
// Step 5: Start the daemon
|
|
||||||
console.log('\nStarting gateway daemon...');
|
|
||||||
try {
|
|
||||||
const pid = startDaemon();
|
|
||||||
console.log(`Gateway started (PID ${pid.toString()})`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: Wait for health
|
|
||||||
console.log('Waiting for gateway to become healthy...');
|
|
||||||
const healthy = await waitForHealth(opts.host, port, 30_000);
|
|
||||||
if (!healthy) {
|
|
||||||
console.error('Gateway did not become healthy within 30 seconds.');
|
|
||||||
console.error(`Check logs: mosaic gateway logs`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('Gateway is healthy.\n');
|
|
||||||
|
|
||||||
// Step 7: Bootstrap — first user setup
|
|
||||||
await bootstrapFirstUser(rl, opts.host, port, meta);
|
|
||||||
|
|
||||||
console.log('\n─── Installation Complete ───');
|
|
||||||
console.log(` Endpoint: http://${opts.host}:${port.toString()}`);
|
|
||||||
console.log(` Config: ${GATEWAY_HOME}`);
|
|
||||||
console.log(` Logs: mosaic gateway logs`);
|
|
||||||
console.log(` Status: mosaic gateway status`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrapFirstUser(
|
|
||||||
rl: ReturnType<typeof createInterface>,
|
|
||||||
host: string,
|
|
||||||
port: number,
|
|
||||||
meta: Omit<GatewayMeta, 'adminToken'> & { adminToken?: string },
|
|
||||||
): Promise<void> {
|
|
||||||
const baseUrl = `http://${host}:${port.toString()}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const statusRes = await fetch(`${baseUrl}/api/bootstrap/status`);
|
|
||||||
if (!statusRes.ok) return;
|
|
||||||
|
|
||||||
const status = (await statusRes.json()) as { needsSetup: boolean };
|
|
||||||
if (!status.needsSetup) {
|
|
||||||
console.log('Admin user already exists — skipping setup.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
console.warn('Could not check bootstrap status — skipping first user setup.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('─── Admin User Setup ───\n');
|
|
||||||
|
|
||||||
const name = (await prompt(rl, 'Admin name: ')).trim();
|
|
||||||
if (!name) {
|
|
||||||
console.error('Name is required.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = (await prompt(rl, 'Admin email: ')).trim();
|
|
||||||
if (!email) {
|
|
||||||
console.error('Email is required.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const password = (await prompt(rl, 'Admin password (min 8 chars): ')).trim();
|
|
||||||
if (password.length < 8) {
|
|
||||||
console.error('Password must be at least 8 characters.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${baseUrl}/api/bootstrap/setup`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name, email, password }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text().catch(() => '');
|
|
||||||
console.error(`Bootstrap failed (${res.status.toString()}): ${body}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = (await res.json()) as {
|
|
||||||
user: { id: string; email: string };
|
|
||||||
token: { plaintext: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save admin token to meta
|
|
||||||
meta.adminToken = result.token.plaintext;
|
|
||||||
writeMeta(meta as GatewayMeta);
|
|
||||||
|
|
||||||
console.log(`\nAdmin user created: ${result.user.email}`);
|
|
||||||
console.log('Admin API token saved to gateway config.');
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Bootstrap error: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { existsSync, readFileSync } from 'node:fs';
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { LOG_FILE } from './daemon.js';
|
|
||||||
|
|
||||||
interface LogsOpts {
|
|
||||||
follow?: boolean;
|
|
||||||
lines?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runLogs(opts: LogsOpts): void {
|
|
||||||
if (!existsSync(LOG_FILE)) {
|
|
||||||
console.log('No log file found. Is the gateway installed?');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.follow) {
|
|
||||||
const lines = opts.lines ?? 50;
|
|
||||||
const tail = spawn('tail', ['-n', lines.toString(), '-f', LOG_FILE], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
});
|
|
||||||
tail.on('error', () => {
|
|
||||||
// Fallback for systems without tail
|
|
||||||
console.log(readLastLines(opts.lines ?? 50));
|
|
||||||
console.log('\n(--follow requires `tail` command)');
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Just print last N lines
|
|
||||||
console.log(readLastLines(opts.lines ?? 50));
|
|
||||||
}
|
|
||||||
|
|
||||||
function readLastLines(n: number): string {
|
|
||||||
const content = readFileSync(LOG_FILE, 'utf-8');
|
|
||||||
const lines = content.split('\n');
|
|
||||||
return lines.slice(-n).join('\n');
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { getDaemonPid, readMeta, LOG_FILE, GATEWAY_HOME } from './daemon.js';
|
|
||||||
|
|
||||||
interface GatewayOpts {
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
token?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceStatus {
|
|
||||||
name: string;
|
|
||||||
status: string;
|
|
||||||
latency?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdminHealth {
|
|
||||||
status: string;
|
|
||||||
services: {
|
|
||||||
database: { status: string; latencyMs: number };
|
|
||||||
cache: { status: string; latencyMs: number };
|
|
||||||
};
|
|
||||||
agentPool?: { active: number };
|
|
||||||
providers?: Array<{ name: string; available: boolean; models: number }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runStatus(opts: GatewayOpts): Promise<void> {
|
|
||||||
const meta = readMeta();
|
|
||||||
const pid = getDaemonPid();
|
|
||||||
|
|
||||||
console.log('Mosaic Gateway Status');
|
|
||||||
console.log('─────────────────────');
|
|
||||||
|
|
||||||
// Daemon status
|
|
||||||
if (pid !== null) {
|
|
||||||
console.log(` Status: running (PID ${pid.toString()})`);
|
|
||||||
} else {
|
|
||||||
console.log(' Status: stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Version
|
|
||||||
console.log(` Version: ${meta?.version ?? 'unknown'}`);
|
|
||||||
|
|
||||||
// Endpoint
|
|
||||||
const host = opts.host;
|
|
||||||
const port = opts.port;
|
|
||||||
console.log(` Endpoint: http://${host}:${port.toString()}`);
|
|
||||||
console.log(` Config: ${GATEWAY_HOME}`);
|
|
||||||
console.log(` Logs: ${LOG_FILE}`);
|
|
||||||
|
|
||||||
if (pid === null) return;
|
|
||||||
|
|
||||||
// Health check
|
|
||||||
try {
|
|
||||||
const healthRes = await fetch(`http://${host}:${port.toString()}/health`);
|
|
||||||
if (!healthRes.ok) {
|
|
||||||
console.log('\n Health: unreachable');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
console.log('\n Health: unreachable');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin health (requires token)
|
|
||||||
const token = opts.token ?? meta?.adminToken;
|
|
||||||
if (!token) {
|
|
||||||
console.log(
|
|
||||||
'\n (No admin token — run `mosaic gateway config` to set one for detailed status)',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`http://${host}:${port.toString()}/api/admin/health`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
console.log('\n Admin health: unauthorized or unavailable');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const health = (await res.json()) as AdminHealth;
|
|
||||||
|
|
||||||
console.log('\n Services:');
|
|
||||||
const services: ServiceStatus[] = [
|
|
||||||
{
|
|
||||||
name: 'Database',
|
|
||||||
status: health.services.database.status,
|
|
||||||
latency: `${health.services.database.latencyMs.toString()}ms`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cache',
|
|
||||||
status: health.services.cache.status,
|
|
||||||
latency: `${health.services.cache.latencyMs.toString()}ms`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const svc of services) {
|
|
||||||
const latStr = svc.latency ? ` (${svc.latency})` : '';
|
|
||||||
console.log(` ${svc.name}:${' '.repeat(10 - svc.name.length)}${svc.status}${latStr}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (health.providers && health.providers.length > 0) {
|
|
||||||
const available = health.providers.filter((p) => p.available);
|
|
||||||
const names = available.map((p) => p.name).join(', ');
|
|
||||||
console.log(`\n Providers: ${available.length.toString()} active (${names})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (health.agentPool) {
|
|
||||||
console.log(` Sessions: ${health.agentPool.active.toString()} active`);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
console.log('\n Admin health: connection error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { existsSync, rmSync } from 'node:fs';
|
|
||||||
import { createInterface } from 'node:readline';
|
|
||||||
import {
|
|
||||||
GATEWAY_HOME,
|
|
||||||
getDaemonPid,
|
|
||||||
readMeta,
|
|
||||||
stopDaemon,
|
|
||||||
uninstallGatewayPackage,
|
|
||||||
} from './daemon.js';
|
|
||||||
|
|
||||||
export async function runUninstall(): Promise<void> {
|
|
||||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
try {
|
|
||||||
await doUninstall(rl);
|
|
||||||
} finally {
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
|
||||||
return new Promise((resolve) => rl.question(question, resolve));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
|
|
||||||
const meta = readMeta();
|
|
||||||
if (!meta) {
|
|
||||||
console.log('Gateway is not installed.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
|
|
||||||
if (answer.toLowerCase() !== 'y') {
|
|
||||||
console.log('Aborted.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop if running
|
|
||||||
if (getDaemonPid() !== null) {
|
|
||||||
console.log('Stopping gateway daemon...');
|
|
||||||
try {
|
|
||||||
await stopDaemon();
|
|
||||||
console.log('Stopped.');
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`Warning: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove config/data
|
|
||||||
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
|
|
||||||
if (removeData.toLowerCase() === 'y') {
|
|
||||||
if (existsSync(GATEWAY_HOME)) {
|
|
||||||
rmSync(GATEWAY_HOME, { recursive: true, force: true });
|
|
||||||
console.log('Gateway data removed.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uninstall npm package
|
|
||||||
console.log('Uninstalling npm package...');
|
|
||||||
uninstallGatewayPackage();
|
|
||||||
|
|
||||||
console.log('\nGateway uninstalled.');
|
|
||||||
}
|
|
||||||
@@ -1,772 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { createRequire } from 'node:module';
|
|
||||||
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 = fwScript('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 = fwScript('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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a path under the framework tools directory. Prefers the version
|
|
||||||
* bundled in the @mosaicstack/mosaic npm package (always matches the installed
|
|
||||||
* CLI version) over the deployed copy in ~/.config/mosaic/ (may be stale).
|
|
||||||
*/
|
|
||||||
function resolveTool(...segments: string[]): string {
|
|
||||||
try {
|
|
||||||
const req = createRequire(import.meta.url);
|
|
||||||
const mosaicPkg = dirname(req.resolve('@mosaicstack/mosaic/package.json'));
|
|
||||||
const bundled = join(mosaicPkg, 'framework', 'tools', ...segments);
|
|
||||||
if (existsSync(bundled)) return bundled;
|
|
||||||
} catch {
|
|
||||||
// Fall through to deployed copy
|
|
||||||
}
|
|
||||||
return join(MOSAIC_HOME, 'tools', ...segments);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fwScript(name: string): string {
|
|
||||||
return resolveTool('_scripts', name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toolScript(toolDir: string, name: string): string {
|
|
||||||
return resolveTool(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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
import type { Command } from 'commander';
|
|
||||||
import { withAuth } from './with-auth.js';
|
|
||||||
import { selectItem } from './select-dialog.js';
|
|
||||||
import {
|
|
||||||
fetchMissions,
|
|
||||||
fetchMission,
|
|
||||||
createMission,
|
|
||||||
updateMission,
|
|
||||||
fetchMissionTasks,
|
|
||||||
createMissionTask,
|
|
||||||
updateMissionTask,
|
|
||||||
fetchProjects,
|
|
||||||
} from '../tui/gateway-api.js';
|
|
||||||
import type { MissionInfo, MissionTaskInfo } from '../tui/gateway-api.js';
|
|
||||||
|
|
||||||
function formatMission(m: MissionInfo): string {
|
|
||||||
return `${m.name} — ${m.status}${m.phase ? ` (${m.phase})` : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showMissionDetail(m: MissionInfo) {
|
|
||||||
console.log(` ID: ${m.id}`);
|
|
||||||
console.log(` Name: ${m.name}`);
|
|
||||||
console.log(` Status: ${m.status}`);
|
|
||||||
console.log(` Phase: ${m.phase ?? '—'}`);
|
|
||||||
console.log(` Project: ${m.projectId ?? '—'}`);
|
|
||||||
console.log(` Description: ${m.description ?? '—'}`);
|
|
||||||
console.log(` Created: ${new Date(m.createdAt).toLocaleString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showTaskDetail(t: MissionTaskInfo) {
|
|
||||||
console.log(` ID: ${t.id}`);
|
|
||||||
console.log(` Status: ${t.status}`);
|
|
||||||
console.log(` Description: ${t.description ?? '—'}`);
|
|
||||||
console.log(` Notes: ${t.notes ?? '—'}`);
|
|
||||||
console.log(` PR: ${t.pr ?? '—'}`);
|
|
||||||
console.log(` Created: ${new Date(t.createdAt).toLocaleString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerMissionCommand(program: Command) {
|
|
||||||
const cmd = program
|
|
||||||
.command('mission')
|
|
||||||
.description('Manage missions')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
|
||||||
.option('--list', 'List all missions')
|
|
||||||
.option('--init', 'Create a new mission')
|
|
||||||
.option('--plan <idOrName>', 'Run PRD wizard for a mission')
|
|
||||||
.option('--update <idOrName>', 'Update a mission')
|
|
||||||
.option('--project <idOrName>', 'Scope to project')
|
|
||||||
.argument('[id]', 'Show mission detail by ID')
|
|
||||||
.action(
|
|
||||||
async (
|
|
||||||
id: string | undefined,
|
|
||||||
opts: {
|
|
||||||
gateway: string;
|
|
||||||
list?: boolean;
|
|
||||||
init?: boolean;
|
|
||||||
plan?: string;
|
|
||||||
update?: string;
|
|
||||||
project?: string;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const auth = await withAuth(opts.gateway);
|
|
||||||
|
|
||||||
if (opts.list) {
|
|
||||||
return listMissions(auth.gateway, auth.cookie);
|
|
||||||
}
|
|
||||||
if (opts.init) {
|
|
||||||
return initMission(auth.gateway, auth.cookie);
|
|
||||||
}
|
|
||||||
if (opts.plan) {
|
|
||||||
return planMission(auth.gateway, auth.cookie, opts.plan, opts.project);
|
|
||||||
}
|
|
||||||
if (opts.update) {
|
|
||||||
return updateMissionWizard(auth.gateway, auth.cookie, opts.update);
|
|
||||||
}
|
|
||||||
if (id) {
|
|
||||||
return showMission(auth.gateway, auth.cookie, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: interactive select
|
|
||||||
return interactiveSelect(auth.gateway, auth.cookie);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Task subcommand
|
|
||||||
cmd
|
|
||||||
.command('task')
|
|
||||||
.description('Manage mission tasks')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
|
||||||
.option('--list', 'List tasks for a mission')
|
|
||||||
.option('--new', 'Create a task')
|
|
||||||
.option('--update <taskId>', 'Update a task')
|
|
||||||
.option('--mission <idOrName>', 'Mission ID or name')
|
|
||||||
.argument('[taskId]', 'Show task detail')
|
|
||||||
.action(
|
|
||||||
async (
|
|
||||||
taskId: string | undefined,
|
|
||||||
taskOpts: {
|
|
||||||
gateway: string;
|
|
||||||
list?: boolean;
|
|
||||||
new?: boolean;
|
|
||||||
update?: string;
|
|
||||||
mission?: string;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const auth = await withAuth(taskOpts.gateway);
|
|
||||||
|
|
||||||
const missionId = await resolveMissionId(auth.gateway, auth.cookie, taskOpts.mission);
|
|
||||||
if (!missionId) return;
|
|
||||||
|
|
||||||
if (taskOpts.list) {
|
|
||||||
return listTasks(auth.gateway, auth.cookie, missionId);
|
|
||||||
}
|
|
||||||
if (taskOpts.new) {
|
|
||||||
return createTaskWizard(auth.gateway, auth.cookie, missionId);
|
|
||||||
}
|
|
||||||
if (taskOpts.update) {
|
|
||||||
return updateTaskWizard(auth.gateway, auth.cookie, missionId, taskOpts.update);
|
|
||||||
}
|
|
||||||
if (taskId) {
|
|
||||||
return showTask(auth.gateway, auth.cookie, missionId, taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return listTasks(auth.gateway, auth.cookie, missionId);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return cmd;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveMissionByName(
|
|
||||||
gateway: string,
|
|
||||||
cookie: string,
|
|
||||||
idOrName: string,
|
|
||||||
): Promise<MissionInfo | undefined> {
|
|
||||||
const missions = await fetchMissions(gateway, cookie);
|
|
||||||
return missions.find((m) => m.id === idOrName || m.name === idOrName);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveMissionId(
|
|
||||||
gateway: string,
|
|
||||||
cookie: string,
|
|
||||||
idOrName?: string,
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
if (idOrName) {
|
|
||||||
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
|
||||||
if (!mission) {
|
|
||||||
console.error(`Mission "${idOrName}" not found.`);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return mission.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interactive select
|
|
||||||
const missions = await fetchMissions(gateway, cookie);
|
|
||||||
const selected = await selectItem(missions, {
|
|
||||||
message: 'Select a mission:',
|
|
||||||
render: formatMission,
|
|
||||||
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
|
|
||||||
});
|
|
||||||
return selected?.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listMissions(gateway: string, cookie: string) {
|
|
||||||
const missions = await fetchMissions(gateway, cookie);
|
|
||||||
if (missions.length === 0) {
|
|
||||||
console.log('No missions found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`Missions (${missions.length}):\n`);
|
|
||||||
for (const m of missions) {
|
|
||||||
const phase = m.phase ? ` [${m.phase}]` : '';
|
|
||||||
console.log(` ${m.name} ${m.status}${phase} ${m.id.slice(0, 8)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showMission(gateway: string, cookie: string, id: string) {
|
|
||||||
try {
|
|
||||||
const mission = await fetchMission(gateway, cookie, id);
|
|
||||||
showMissionDetail(mission);
|
|
||||||
} catch {
|
|
||||||
// Try resolving by name
|
|
||||||
const m = await resolveMissionByName(gateway, cookie, id);
|
|
||||||
if (!m) {
|
|
||||||
console.error(`Mission "${id}" not found.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
showMissionDetail(m);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function interactiveSelect(gateway: string, cookie: string) {
|
|
||||||
const missions = await fetchMissions(gateway, cookie);
|
|
||||||
const selected = await selectItem(missions, {
|
|
||||||
message: 'Select a mission:',
|
|
||||||
render: formatMission,
|
|
||||||
emptyMessage: 'No missions found. Create one with `mosaic mission --init`.',
|
|
||||||
});
|
|
||||||
if (selected) {
|
|
||||||
showMissionDetail(selected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initMission(gateway: string, cookie: string) {
|
|
||||||
const readline = await import('node:readline');
|
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const name = await ask('Mission name: ');
|
|
||||||
if (!name.trim()) {
|
|
||||||
console.error('Name is required.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Project selection
|
|
||||||
const projects = await fetchProjects(gateway, cookie);
|
|
||||||
let projectId: string | undefined;
|
|
||||||
if (projects.length > 0) {
|
|
||||||
const selected = await selectItem(projects, {
|
|
||||||
message: 'Assign to project (required):',
|
|
||||||
render: (p) => `${p.name} (${p.status})`,
|
|
||||||
emptyMessage: 'No projects found.',
|
|
||||||
});
|
|
||||||
if (selected) projectId = selected.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const description = await ask('Description (optional): ');
|
|
||||||
|
|
||||||
const mission = await createMission(gateway, cookie, {
|
|
||||||
name: name.trim(),
|
|
||||||
projectId,
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
status: 'planning',
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\nMission "${mission.name}" created (${mission.id}).`);
|
|
||||||
} finally {
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function planMission(
|
|
||||||
gateway: string,
|
|
||||||
cookie: string,
|
|
||||||
idOrName: string,
|
|
||||||
_projectIdOrName?: string,
|
|
||||||
) {
|
|
||||||
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
|
||||||
if (!mission) {
|
|
||||||
console.error(`Mission "${idOrName}" not found.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Planning mission: ${mission.name}\n`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { runPrdWizard } = await import('@mosaicstack/prdy');
|
|
||||||
await runPrdWizard({
|
|
||||||
name: mission.name,
|
|
||||||
projectPath: process.cwd(),
|
|
||||||
interactive: true,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateMissionWizard(gateway: string, cookie: string, idOrName: string) {
|
|
||||||
const mission = await resolveMissionByName(gateway, cookie, idOrName);
|
|
||||||
if (!mission) {
|
|
||||||
console.error(`Mission "${idOrName}" not found.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const readline = await import('node:readline');
|
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`Updating mission: ${mission.name}\n`);
|
|
||||||
|
|
||||||
const name = await ask(`Name [${mission.name}]: `);
|
|
||||||
const description = await ask(`Description [${mission.description ?? 'none'}]: `);
|
|
||||||
const status = await ask(`Status [${mission.status}]: `);
|
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {};
|
|
||||||
if (name.trim()) updates['name'] = name.trim();
|
|
||||||
if (description.trim()) updates['description'] = description.trim();
|
|
||||||
if (status.trim()) updates['status'] = status.trim();
|
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
|
||||||
console.log('No changes.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await updateMission(gateway, cookie, mission.id, updates);
|
|
||||||
console.log(`\nMission "${updated.name}" updated.`);
|
|
||||||
} finally {
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Task operations ──
|
|
||||||
|
|
||||||
async function listTasks(gateway: string, cookie: string, missionId: string) {
|
|
||||||
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
|
|
||||||
if (tasks.length === 0) {
|
|
||||||
console.log('No tasks found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`Tasks (${tasks.length}):\n`);
|
|
||||||
for (const t of tasks) {
|
|
||||||
const desc = t.description ? ` — ${t.description.slice(0, 60)}` : '';
|
|
||||||
console.log(` ${t.id.slice(0, 8)} ${t.status}${desc}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showTask(gateway: string, cookie: string, missionId: string, taskId: string) {
|
|
||||||
const tasks = await fetchMissionTasks(gateway, cookie, missionId);
|
|
||||||
const task = tasks.find((t) => t.id === taskId);
|
|
||||||
if (!task) {
|
|
||||||
console.error(`Task "${taskId}" not found.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
showTaskDetail(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTaskWizard(gateway: string, cookie: string, missionId: string) {
|
|
||||||
const readline = await import('node:readline');
|
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const description = await ask('Task description: ');
|
|
||||||
if (!description.trim()) {
|
|
||||||
console.error('Description is required.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = await ask('Status [not-started]: ');
|
|
||||||
|
|
||||||
const task = await createMissionTask(gateway, cookie, missionId, {
|
|
||||||
description: description.trim(),
|
|
||||||
status: status.trim() || 'not-started',
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\nTask created (${task.id}).`);
|
|
||||||
} finally {
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateTaskWizard(
|
|
||||||
gateway: string,
|
|
||||||
cookie: string,
|
|
||||||
missionId: string,
|
|
||||||
taskId: string,
|
|
||||||
) {
|
|
||||||
const readline = await import('node:readline');
|
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const status = await ask('New status: ');
|
|
||||||
const notes = await ask('Notes (optional): ');
|
|
||||||
const pr = await ask('PR (optional): ');
|
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {};
|
|
||||||
if (status.trim()) updates['status'] = status.trim();
|
|
||||||
if (notes.trim()) updates['notes'] = notes.trim();
|
|
||||||
if (pr.trim()) updates['pr'] = pr.trim();
|
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
|
||||||
console.log('No changes.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await updateMissionTask(gateway, cookie, missionId, taskId, updates);
|
|
||||||
console.log(`\nTask ${updated.id.slice(0, 8)} updated (${updated.status}).`);
|
|
||||||
} finally {
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import type { Command } from 'commander';
|
|
||||||
import { withAuth } from './with-auth.js';
|
|
||||||
import { fetchProjects } from '../tui/gateway-api.js';
|
|
||||||
|
|
||||||
export function registerPrdyCommand(program: Command) {
|
|
||||||
const cmd = program
|
|
||||||
.command('prdy')
|
|
||||||
.description('PRD wizard — create and manage Product Requirement Documents')
|
|
||||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
|
||||||
.option('--init [name]', 'Create a new PRD')
|
|
||||||
.option('--update [name]', 'Update an existing PRD')
|
|
||||||
.option('--project <idOrName>', 'Scope to project')
|
|
||||||
.action(
|
|
||||||
async (opts: {
|
|
||||||
gateway: string;
|
|
||||||
init?: string | boolean;
|
|
||||||
update?: string | boolean;
|
|
||||||
project?: string;
|
|
||||||
}) => {
|
|
||||||
// Detect project context when --project flag is provided
|
|
||||||
if (opts.project) {
|
|
||||||
try {
|
|
||||||
const auth = await withAuth(opts.gateway);
|
|
||||||
const projects = await fetchProjects(auth.gateway, auth.cookie);
|
|
||||||
const match = projects.find((p) => p.id === opts.project || p.name === opts.project);
|
|
||||||
if (match) {
|
|
||||||
console.log(`Project context: ${match.name} (${match.id})\n`);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Gateway not available — proceed without project context
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { runPrdWizard } = await import('@mosaicstack/prdy');
|
|
||||||
const name =
|
|
||||||
typeof opts.init === 'string'
|
|
||||||
? opts.init
|
|
||||||
: typeof opts.update === 'string'
|
|
||||||
? opts.update
|
|
||||||
: 'untitled';
|
|
||||||
await runPrdWizard({
|
|
||||||
name,
|
|
||||||
projectPath: process.cwd(),
|
|
||||||
interactive: true,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return cmd;
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
/**
|
|
||||||
* Interactive item selection. Uses @clack/prompts when TTY, falls back to numbered list.
|
|
||||||
*/
|
|
||||||
export async function selectItem<T>(
|
|
||||||
items: T[],
|
|
||||||
opts: {
|
|
||||||
message: string;
|
|
||||||
render: (item: T) => string;
|
|
||||||
emptyMessage?: string;
|
|
||||||
},
|
|
||||||
): Promise<T | undefined> {
|
|
||||||
if (items.length === 0) {
|
|
||||||
console.log(opts.emptyMessage ?? 'No items found.');
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTTY = process.stdin.isTTY;
|
|
||||||
|
|
||||||
if (isTTY) {
|
|
||||||
try {
|
|
||||||
const { select } = await import('@clack/prompts');
|
|
||||||
const result = await select({
|
|
||||||
message: opts.message,
|
|
||||||
options: items.map((item, i) => ({
|
|
||||||
value: i,
|
|
||||||
label: opts.render(item),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof result === 'symbol') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return items[result as number];
|
|
||||||
} catch {
|
|
||||||
// Fall through to non-interactive
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-interactive: display numbered list and read a number
|
|
||||||
console.log(`\n${opts.message}\n`);
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
console.log(` ${i + 1}. ${opts.render(items[i]!)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const readline = await import('node:readline');
|
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
const answer = await new Promise<string>((resolve) => rl.question('\nSelect: ', resolve));
|
|
||||||
rl.close();
|
|
||||||
|
|
||||||
const index = parseInt(answer, 10) - 1;
|
|
||||||
if (isNaN(index) || index < 0 || index >= items.length) {
|
|
||||||
console.error('Invalid selection.');
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return items[index];
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { AuthResult } from '../auth.js';
|
|
||||||
|
|
||||||
export interface AuthContext {
|
|
||||||
gateway: string;
|
|
||||||
session: AuthResult;
|
|
||||||
cookie: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and validate the user's auth session.
|
|
||||||
* Exits with an error message if not signed in or session expired.
|
|
||||||
*/
|
|
||||||
export async function withAuth(gateway: string): Promise<AuthContext> {
|
|
||||||
const { loadSession, validateSession } = await import('../auth.js');
|
|
||||||
|
|
||||||
const session = loadSession(gateway);
|
|
||||||
if (!session) {
|
|
||||||
console.error('Not signed in. Run `mosaic login` first.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = await validateSession(gateway, session.cookie);
|
|
||||||
if (!valid) {
|
|
||||||
console.error('Session expired. Run `mosaic login` again.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { gateway, session, cookie: session.cookie };
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const VERSION = '0.0.0';
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
||||||
import { Box, useApp, useInput } from 'ink';
|
|
||||||
import type { ParsedCommand } from '@mosaicstack/types';
|
|
||||||
import { TopBar } from './components/top-bar.js';
|
|
||||||
import { BottomBar } from './components/bottom-bar.js';
|
|
||||||
import { MessageList } from './components/message-list.js';
|
|
||||||
import { InputBar } from './components/input-bar.js';
|
|
||||||
import { Sidebar } from './components/sidebar.js';
|
|
||||||
import { SearchBar } from './components/search-bar.js';
|
|
||||||
import { useSocket } from './hooks/use-socket.js';
|
|
||||||
import { useGitInfo } from './hooks/use-git-info.js';
|
|
||||||
import { useViewport } from './hooks/use-viewport.js';
|
|
||||||
import { useAppMode } from './hooks/use-app-mode.js';
|
|
||||||
import { useConversations } from './hooks/use-conversations.js';
|
|
||||||
import { useSearch } from './hooks/use-search.js';
|
|
||||||
import { executeHelp, executeStatus, executeHistory, commandRegistry } from './commands/index.js';
|
|
||||||
import { fetchConversationMessages } from './gateway-api.js';
|
|
||||||
import { expandFileRefs, hasFileRefs, handleAttachCommand } from './file-ref.js';
|
|
||||||
|
|
||||||
export interface TuiAppProps {
|
|
||||||
gatewayUrl: string;
|
|
||||||
conversationId?: string;
|
|
||||||
sessionCookie?: string;
|
|
||||||
initialModel?: string;
|
|
||||||
initialProvider?: string;
|
|
||||||
agentId?: string;
|
|
||||||
agentName?: string;
|
|
||||||
projectId?: string;
|
|
||||||
/** CLI package version passed from the entry point (cli.ts). */
|
|
||||||
version?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TuiApp({
|
|
||||||
gatewayUrl,
|
|
||||||
conversationId,
|
|
||||||
sessionCookie,
|
|
||||||
initialModel,
|
|
||||||
initialProvider,
|
|
||||||
agentId,
|
|
||||||
agentName,
|
|
||||||
projectId: _projectId,
|
|
||||||
version = '0.0.0',
|
|
||||||
}: TuiAppProps) {
|
|
||||||
const { exit } = useApp();
|
|
||||||
const gitInfo = useGitInfo();
|
|
||||||
const appMode = useAppMode();
|
|
||||||
|
|
||||||
const socket = useSocket({
|
|
||||||
gatewayUrl,
|
|
||||||
sessionCookie,
|
|
||||||
initialConversationId: conversationId,
|
|
||||||
initialModel,
|
|
||||||
initialProvider,
|
|
||||||
agentId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const conversations = useConversations({ gatewayUrl, sessionCookie });
|
|
||||||
|
|
||||||
const viewport = useViewport({ totalItems: socket.messages.length });
|
|
||||||
|
|
||||||
const search = useSearch(socket.messages);
|
|
||||||
|
|
||||||
// Scroll to current match when it changes
|
|
||||||
const currentMatch = search.matches[search.currentMatchIndex];
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentMatch && appMode.mode === 'search') {
|
|
||||||
viewport.scrollTo(currentMatch.messageIndex);
|
|
||||||
}
|
|
||||||
}, [currentMatch, appMode.mode, viewport]);
|
|
||||||
|
|
||||||
// Compute highlighted message indices for MessageList
|
|
||||||
const highlightedMessageIndices = useMemo(() => {
|
|
||||||
if (search.matches.length === 0) return undefined;
|
|
||||||
return new Set(search.matches.map((m) => m.messageIndex));
|
|
||||||
}, [search.matches]);
|
|
||||||
|
|
||||||
const currentHighlightIndex = currentMatch?.messageIndex;
|
|
||||||
|
|
||||||
const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0);
|
|
||||||
|
|
||||||
// Controlled input state — held here so Ctrl+C can clear it
|
|
||||||
const [tuiInput, setTuiInput] = useState('');
|
|
||||||
// Ctrl+C double-press: first press with empty input shows hint; second exits
|
|
||||||
const ctrlCPendingExit = useRef(false);
|
|
||||||
// Flag to suppress the character that ink-text-input leaks when a Ctrl+key
|
|
||||||
// combo is handled by the top-level useInput handler (e.g. Ctrl+T → 't').
|
|
||||||
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(
|
|
||||||
(parsed: ParsedCommand) => {
|
|
||||||
switch (parsed.command) {
|
|
||||||
case 'help':
|
|
||||||
case 'h': {
|
|
||||||
const result = executeHelp(parsed);
|
|
||||||
socket.addSystemMessage(result);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'status':
|
|
||||||
case 's': {
|
|
||||||
const result = executeStatus(parsed, {
|
|
||||||
connected: socket.connected,
|
|
||||||
model: socket.modelName,
|
|
||||||
provider: socket.providerName,
|
|
||||||
sessionId: socket.conversationId ?? null,
|
|
||||||
tokenCount: socket.tokenUsage.total,
|
|
||||||
});
|
|
||||||
socket.addSystemMessage(result);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'clear':
|
|
||||||
socket.clearMessages();
|
|
||||||
break;
|
|
||||||
case 'new':
|
|
||||||
case 'n':
|
|
||||||
void conversations
|
|
||||||
.createConversation()
|
|
||||||
.then((conv) => {
|
|
||||||
if (conv) {
|
|
||||||
socket.switchConversation(conv.id);
|
|
||||||
appMode.setMode('chat');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
socket.addSystemMessage('Failed to create new conversation.');
|
|
||||||
});
|
|
||||||
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':
|
|
||||||
if (socket.isStreaming && socket.socketRef.current?.connected && socket.conversationId) {
|
|
||||||
socket.socketRef.current.emit('abort', {
|
|
||||||
conversationId: socket.conversationId,
|
|
||||||
});
|
|
||||||
socket.addSystemMessage('Abort signal sent.');
|
|
||||||
} else {
|
|
||||||
socket.addSystemMessage('No active stream to stop.');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'cost': {
|
|
||||||
const u = socket.tokenUsage;
|
|
||||||
socket.addSystemMessage(
|
|
||||||
`Tokens — input: ${u.input}, output: ${u.output}, total: ${u.total}\nCost: $${u.cost.toFixed(6)}`,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'history':
|
|
||||||
case 'hist': {
|
|
||||||
void executeHistory({
|
|
||||||
conversationId: socket.conversationId,
|
|
||||||
gatewayUrl,
|
|
||||||
sessionCookie,
|
|
||||||
fetchMessages: fetchConversationMessages,
|
|
||||||
})
|
|
||||||
.then((result) => {
|
|
||||||
socket.addSystemMessage(result);
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
socket.addSystemMessage(`Failed to fetch history: ${msg}`);
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
socket.addSystemMessage(`Local command not implemented: /${parsed.command}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[socket],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleGatewayCommand = useCallback(
|
|
||||||
(parsed: ParsedCommand) => {
|
|
||||||
if (!socket.socketRef.current?.connected) {
|
|
||||||
socket.addSystemMessage('Not connected to gateway. Command cannot be executed.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
socket.socketRef.current.emit('command:execute', {
|
|
||||||
conversationId: socket.conversationId ?? '',
|
|
||||||
command: parsed.command,
|
|
||||||
args: parsed.args ?? undefined,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[socket],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSwitchConversation = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
socket.switchConversation(id);
|
|
||||||
appMode.setMode('chat');
|
|
||||||
},
|
|
||||||
[socket, appMode],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDeleteConversation = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
void conversations
|
|
||||||
.deleteConversation(id)
|
|
||||||
.then((ok) => {
|
|
||||||
if (ok && id === socket.conversationId) {
|
|
||||||
socket.clearMessages();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
},
|
|
||||||
[conversations, socket],
|
|
||||||
);
|
|
||||||
|
|
||||||
useInput((ch, key) => {
|
|
||||||
// Ctrl+C: clear input → show hint → second empty press exits
|
|
||||||
if (key.ctrl && ch === 'c') {
|
|
||||||
if (tuiInput) {
|
|
||||||
setTuiInput('');
|
|
||||||
ctrlCPendingExit.current = false;
|
|
||||||
} else if (ctrlCPendingExit.current) {
|
|
||||||
exit();
|
|
||||||
} else {
|
|
||||||
ctrlCPendingExit.current = true;
|
|
||||||
socket.addSystemMessage('Press Ctrl+C again to exit.');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Any other key resets the pending-exit flag
|
|
||||||
ctrlCPendingExit.current = false;
|
|
||||||
// Ctrl+L: toggle sidebar (refresh on open)
|
|
||||||
if (key.ctrl && ch === 'l') {
|
|
||||||
ctrlJustFired.current = true;
|
|
||||||
queueMicrotask(() => {
|
|
||||||
ctrlJustFired.current = false;
|
|
||||||
});
|
|
||||||
const willOpen = !appMode.sidebarOpen;
|
|
||||||
appMode.toggleSidebar();
|
|
||||||
if (willOpen) {
|
|
||||||
void conversations.refresh();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Ctrl+N: create new conversation and switch to it
|
|
||||||
if (key.ctrl && ch === 'n') {
|
|
||||||
ctrlJustFired.current = true;
|
|
||||||
queueMicrotask(() => {
|
|
||||||
ctrlJustFired.current = false;
|
|
||||||
});
|
|
||||||
void conversations
|
|
||||||
.createConversation()
|
|
||||||
.then((conv) => {
|
|
||||||
if (conv) {
|
|
||||||
socket.switchConversation(conv.id);
|
|
||||||
appMode.setMode('chat');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Ctrl+K: toggle search mode
|
|
||||||
if (key.ctrl && ch === 'k') {
|
|
||||||
ctrlJustFired.current = true;
|
|
||||||
queueMicrotask(() => {
|
|
||||||
ctrlJustFired.current = false;
|
|
||||||
});
|
|
||||||
if (appMode.mode === 'search') {
|
|
||||||
search.clear();
|
|
||||||
appMode.setMode('chat');
|
|
||||||
} else {
|
|
||||||
appMode.setMode('search');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Page Up / Page Down: scroll message history (only in chat mode)
|
|
||||||
if (appMode.mode === 'chat') {
|
|
||||||
if (key.pageUp) {
|
|
||||||
viewport.scrollBy(-viewport.viewportSize);
|
|
||||||
}
|
|
||||||
if (key.pageDown) {
|
|
||||||
viewport.scrollBy(viewport.viewportSize);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ctrl+T: cycle thinking level
|
|
||||||
if (key.ctrl && ch === 't') {
|
|
||||||
ctrlJustFired.current = true;
|
|
||||||
queueMicrotask(() => {
|
|
||||||
ctrlJustFired.current = false;
|
|
||||||
});
|
|
||||||
const levels = socket.availableThinkingLevels;
|
|
||||||
if (levels.length > 0) {
|
|
||||||
const currentIdx = levels.indexOf(socket.thinkingLevel);
|
|
||||||
const nextIdx = (currentIdx + 1) % levels.length;
|
|
||||||
const next = levels[nextIdx];
|
|
||||||
if (next) {
|
|
||||||
socket.setThinkingLevel(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Escape: return to chat from sidebar/search; in chat, scroll to bottom
|
|
||||||
if (key.escape) {
|
|
||||||
if (appMode.mode === 'search') {
|
|
||||||
search.clear();
|
|
||||||
appMode.setMode('chat');
|
|
||||||
} else if (appMode.mode === 'sidebar') {
|
|
||||||
appMode.setMode('chat');
|
|
||||||
} else if (appMode.mode === 'chat') {
|
|
||||||
viewport.scrollToBottom();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const inputPlaceholder =
|
|
||||||
appMode.mode === 'sidebar'
|
|
||||||
? 'focus is on sidebar… press Esc to return'
|
|
||||||
: appMode.mode === 'search'
|
|
||||||
? 'search mode… press Esc to return'
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const isSearchMode = appMode.mode === 'search';
|
|
||||||
|
|
||||||
const messageArea = (
|
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
|
||||||
<MessageList
|
|
||||||
messages={socket.messages}
|
|
||||||
isStreaming={socket.isStreaming}
|
|
||||||
currentStreamText={socket.currentStreamText}
|
|
||||||
currentThinkingText={socket.currentThinkingText}
|
|
||||||
activeToolCalls={socket.activeToolCalls}
|
|
||||||
scrollOffset={viewport.scrollOffset}
|
|
||||||
viewportSize={viewport.viewportSize}
|
|
||||||
isScrolledUp={viewport.isScrolledUp}
|
|
||||||
highlightedMessageIndices={highlightedMessageIndices}
|
|
||||||
currentHighlightIndex={currentHighlightIndex}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isSearchMode && (
|
|
||||||
<SearchBar
|
|
||||||
query={search.query}
|
|
||||||
onQueryChange={search.setQuery}
|
|
||||||
totalMatches={search.totalMatches}
|
|
||||||
currentMatch={search.currentMatchIndex}
|
|
||||||
onNext={search.nextMatch}
|
|
||||||
onPrev={search.prevMatch}
|
|
||||||
onClose={() => {
|
|
||||||
search.clear();
|
|
||||||
appMode.setMode('chat');
|
|
||||||
}}
|
|
||||||
focused={isSearchMode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<InputBar
|
|
||||||
value={tuiInput}
|
|
||||||
onChange={(val: string) => {
|
|
||||||
// Suppress the character that ink-text-input leaks when a Ctrl+key
|
|
||||||
// combo fires (e.g. Ctrl+T inserts 't'). The ctrlJustFired ref is
|
|
||||||
// set synchronously in the useInput handler and cleared via a
|
|
||||||
// microtask, so this callback sees it as still true on the same
|
|
||||||
// event-loop tick.
|
|
||||||
if (ctrlJustFired.current) {
|
|
||||||
ctrlJustFired.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTuiInput(val);
|
|
||||||
}}
|
|
||||||
onSubmit={sendMessageWithFileRefs}
|
|
||||||
onSystemMessage={socket.addSystemMessage}
|
|
||||||
onLocalCommand={handleLocalCommand}
|
|
||||||
onGatewayCommand={handleGatewayCommand}
|
|
||||||
isStreaming={socket.isStreaming}
|
|
||||||
connected={socket.connected}
|
|
||||||
focused={appMode.mode === 'chat'}
|
|
||||||
placeholder={inputPlaceholder}
|
|
||||||
allCommands={commandRegistry.getAll()}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column" height="100%">
|
|
||||||
<Box marginTop={1} />
|
|
||||||
<TopBar
|
|
||||||
gatewayUrl={gatewayUrl}
|
|
||||||
version={version}
|
|
||||||
modelName={socket.modelName}
|
|
||||||
thinkingLevel={socket.thinkingLevel}
|
|
||||||
contextWindow={socket.tokenUsage.contextWindow}
|
|
||||||
agentName={agentName ?? 'default'}
|
|
||||||
connected={socket.connected}
|
|
||||||
connecting={socket.connecting}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{appMode.sidebarOpen ? (
|
|
||||||
<Box flexDirection="row" flexGrow={1}>
|
|
||||||
<Sidebar
|
|
||||||
conversations={conversations.conversations}
|
|
||||||
activeConversationId={socket.conversationId}
|
|
||||||
selectedIndex={sidebarSelectedIndex}
|
|
||||||
onSelectIndex={setSidebarSelectedIndex}
|
|
||||||
onSwitchConversation={handleSwitchConversation}
|
|
||||||
onDeleteConversation={handleDeleteConversation}
|
|
||||||
loading={conversations.loading}
|
|
||||||
focused={appMode.mode === 'sidebar'}
|
|
||||||
width={30}
|
|
||||||
/>
|
|
||||||
{messageArea}
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Box flexGrow={1}>{messageArea}</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<BottomBar
|
|
||||||
gitInfo={gitInfo}
|
|
||||||
tokenUsage={socket.tokenUsage}
|
|
||||||
connected={socket.connected}
|
|
||||||
connecting={socket.connecting}
|
|
||||||
modelName={socket.modelName}
|
|
||||||
providerName={socket.providerName}
|
|
||||||
thinkingLevel={socket.thinkingLevel}
|
|
||||||
conversationId={socket.conversationId}
|
|
||||||
routingDecision={socket.routingDecision}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
/**
|
|
||||||
* Integration tests for TUI command parsing + registry (P8-019)
|
|
||||||
*
|
|
||||||
* Covers:
|
|
||||||
* - parseSlashCommand() + commandRegistry.find() round-trip for all aliases
|
|
||||||
* - /help, /stop, /cost, /status resolve to 'local' execution
|
|
||||||
* - Unknown commands return null from find()
|
|
||||||
* - Alias resolution: /h → help, /m → model, /n → new, etc.
|
|
||||||
* - filterCommands prefix filtering
|
|
||||||
*/
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { parseSlashCommand } from './parse.js';
|
|
||||||
import { CommandRegistry } from './registry.js';
|
|
||||||
import type { CommandDef } from '@mosaicstack/types';
|
|
||||||
|
|
||||||
// ─── Parse + Registry Round-trip ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('parseSlashCommand + CommandRegistry — integration', () => {
|
|
||||||
let registry: CommandRegistry;
|
|
||||||
|
|
||||||
// Gateway-style commands to simulate a live manifest
|
|
||||||
const gatewayCommands: CommandDef[] = [
|
|
||||||
{
|
|
||||||
name: 'model',
|
|
||||||
description: 'Switch the active model',
|
|
||||||
aliases: ['m'],
|
|
||||||
args: [{ name: 'model-name', type: 'string', optional: false, description: 'Model name' }],
|
|
||||||
scope: 'core',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'thinking',
|
|
||||||
description: 'Set thinking level',
|
|
||||||
aliases: ['t'],
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: 'level',
|
|
||||||
type: 'enum',
|
|
||||||
optional: false,
|
|
||||||
values: ['none', 'low', 'medium', 'high', 'auto'],
|
|
||||||
description: 'Thinking level',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
scope: 'core',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'new',
|
|
||||||
description: 'Start a new conversation',
|
|
||||||
aliases: ['n'],
|
|
||||||
scope: 'core',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'agent',
|
|
||||||
description: 'Switch or list available agents',
|
|
||||||
aliases: ['a'],
|
|
||||||
args: [{ name: 'args', type: 'string', optional: true, description: 'list or <agent-id>' }],
|
|
||||||
scope: 'agent',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'preferences',
|
|
||||||
description: 'View or set user preferences',
|
|
||||||
aliases: ['pref'],
|
|
||||||
args: [
|
|
||||||
{
|
|
||||||
name: 'action',
|
|
||||||
type: 'enum',
|
|
||||||
optional: true,
|
|
||||||
values: ['show', 'set', 'reset'],
|
|
||||||
description: 'Action',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
scope: 'core',
|
|
||||||
execution: 'rest',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'gc',
|
|
||||||
description: 'Trigger garbage collection sweep',
|
|
||||||
aliases: [],
|
|
||||||
scope: 'core',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'mission',
|
|
||||||
description: 'View or set active mission',
|
|
||||||
aliases: [],
|
|
||||||
args: [{ name: 'args', type: 'string', optional: true, description: 'status | set <id>' }],
|
|
||||||
scope: 'agent',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
registry = new CommandRegistry();
|
|
||||||
registry.updateManifest({ version: 1, commands: gatewayCommands, skills: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── parseSlashCommand tests ──
|
|
||||||
|
|
||||||
it('returns null for non-slash input', () => {
|
|
||||||
expect(parseSlashCommand('hello world')).toBeNull();
|
|
||||||
expect(parseSlashCommand('')).toBeNull();
|
|
||||||
expect(parseSlashCommand('model')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses "/model claude-3-opus" → command=model args=claude-3-opus', () => {
|
|
||||||
const parsed = parseSlashCommand('/model claude-3-opus');
|
|
||||||
expect(parsed).not.toBeNull();
|
|
||||||
expect(parsed!.command).toBe('model');
|
|
||||||
expect(parsed!.args).toBe('claude-3-opus');
|
|
||||||
expect(parsed!.raw).toBe('/model claude-3-opus');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses "/gc" with no args → command=gc args=null', () => {
|
|
||||||
const parsed = parseSlashCommand('/gc');
|
|
||||||
expect(parsed).not.toBeNull();
|
|
||||||
expect(parsed!.command).toBe('gc');
|
|
||||||
expect(parsed!.args).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses "/system you are a helpful assistant" → args contains full text', () => {
|
|
||||||
const parsed = parseSlashCommand('/system you are a helpful assistant');
|
|
||||||
expect(parsed!.command).toBe('system');
|
|
||||||
expect(parsed!.args).toBe('you are a helpful assistant');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses "/help" → command=help args=null', () => {
|
|
||||||
const parsed = parseSlashCommand('/help');
|
|
||||||
expect(parsed!.command).toBe('help');
|
|
||||||
expect(parsed!.args).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Round-trip: parse then find ──
|
|
||||||
|
|
||||||
it('round-trip: /m → resolves to "model" command via alias', () => {
|
|
||||||
const parsed = parseSlashCommand('/m claude-3-haiku');
|
|
||||||
expect(parsed).not.toBeNull();
|
|
||||||
const cmd = registry.find(parsed!.command);
|
|
||||||
expect(cmd).not.toBeNull();
|
|
||||||
// /m → model (alias map in registry)
|
|
||||||
expect(cmd!.name === 'model' || cmd!.aliases.includes('m')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('round-trip: /h → resolves to "help" (local command)', () => {
|
|
||||||
const parsed = parseSlashCommand('/h');
|
|
||||||
expect(parsed).not.toBeNull();
|
|
||||||
const cmd = registry.find(parsed!.command);
|
|
||||||
expect(cmd).not.toBeNull();
|
|
||||||
expect(cmd!.name === 'help' || cmd!.aliases.includes('h')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('round-trip: /n → resolves to "new" via gateway manifest', () => {
|
|
||||||
const parsed = parseSlashCommand('/n');
|
|
||||||
expect(parsed).not.toBeNull();
|
|
||||||
const cmd = registry.find(parsed!.command);
|
|
||||||
expect(cmd).not.toBeNull();
|
|
||||||
expect(cmd!.name === 'new' || cmd!.aliases.includes('n')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('round-trip: /a → resolves to "agent" via gateway manifest', () => {
|
|
||||||
const parsed = parseSlashCommand('/a list');
|
|
||||||
expect(parsed).not.toBeNull();
|
|
||||||
const cmd = registry.find(parsed!.command);
|
|
||||||
expect(cmd).not.toBeNull();
|
|
||||||
expect(cmd!.name === 'agent' || cmd!.aliases.includes('a')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('round-trip: /pref → resolves to "preferences" via alias', () => {
|
|
||||||
const parsed = parseSlashCommand('/pref show');
|
|
||||||
expect(parsed).not.toBeNull();
|
|
||||||
const cmd = registry.find(parsed!.command);
|
|
||||||
expect(cmd).not.toBeNull();
|
|
||||||
expect(cmd!.name === 'preferences' || cmd!.aliases.includes('pref')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('round-trip: /t → resolves to "thinking" via alias', () => {
|
|
||||||
const parsed = parseSlashCommand('/t high');
|
|
||||||
expect(parsed).not.toBeNull();
|
|
||||||
const cmd = registry.find(parsed!.command);
|
|
||||||
expect(cmd).not.toBeNull();
|
|
||||||
expect(cmd!.name === 'thinking' || cmd!.aliases.includes('t')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Local commands resolve to 'local' execution ──
|
|
||||||
|
|
||||||
it('/help resolves to local execution', () => {
|
|
||||||
const cmd = registry.find('help');
|
|
||||||
expect(cmd).not.toBeNull();
|
|
||||||
expect(cmd!.execution).toBe('local');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('/stop resolves to local execution', () => {
|
|
||||||
const cmd = registry.find('stop');
|
|
||||||
expect(cmd).not.toBeNull();
|
|
||||||
expect(cmd!.execution).toBe('local');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('/cost resolves to local execution', () => {
|
|
||||||
const cmd = registry.find('cost');
|
|
||||||
expect(cmd).not.toBeNull();
|
|
||||||
expect(cmd!.execution).toBe('local');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('/status resolves to local execution (TUI local override)', () => {
|
|
||||||
const cmd = registry.find('status');
|
|
||||||
expect(cmd).not.toBeNull();
|
|
||||||
// status is 'local' in the TUI registry (local takes precedence over gateway)
|
|
||||||
expect(cmd!.execution).toBe('local');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Unknown commands return null ──
|
|
||||||
|
|
||||||
it('find() returns null for unknown command', () => {
|
|
||||||
expect(registry.find('nonexistent')).toBeNull();
|
|
||||||
expect(registry.find('xyz')).toBeNull();
|
|
||||||
expect(registry.find('')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('find() returns null when no gateway manifest and command not local', () => {
|
|
||||||
const emptyRegistry = new CommandRegistry();
|
|
||||||
expect(emptyRegistry.find('model')).toBeNull();
|
|
||||||
expect(emptyRegistry.find('gc')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── getAll returns combined local + gateway ──
|
|
||||||
|
|
||||||
it('getAll() includes both local and gateway commands', () => {
|
|
||||||
const all = registry.getAll();
|
|
||||||
const names = all.map((c) => c.name);
|
|
||||||
// Local commands
|
|
||||||
expect(names).toContain('help');
|
|
||||||
expect(names).toContain('stop');
|
|
||||||
expect(names).toContain('cost');
|
|
||||||
expect(names).toContain('status');
|
|
||||||
// Gateway commands
|
|
||||||
expect(names).toContain('model');
|
|
||||||
expect(names).toContain('gc');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getLocalCommands() returns only local commands', () => {
|
|
||||||
const local = registry.getLocalCommands();
|
|
||||||
expect(local.every((c) => c.execution === 'local')).toBe(true);
|
|
||||||
expect(local.some((c) => c.name === 'help')).toBe(true);
|
|
||||||
expect(local.some((c) => c.name === 'stop')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── filterCommands (autocomplete) ────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('filterCommands (from CommandAutocomplete)', () => {
|
|
||||||
// Import inline since filterCommands is not exported — replicate the logic here
|
|
||||||
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
|
|
||||||
if (!query) return commands;
|
|
||||||
const q = query.toLowerCase();
|
|
||||||
return commands.filter(
|
|
||||||
(c) =>
|
|
||||||
c.name.includes(q) ||
|
|
||||||
c.aliases.some((a) => a.includes(q)) ||
|
|
||||||
c.description.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const commands: CommandDef[] = [
|
|
||||||
{
|
|
||||||
name: 'model',
|
|
||||||
description: 'Switch the active model',
|
|
||||||
aliases: ['m'],
|
|
||||||
scope: 'core',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'mission',
|
|
||||||
description: 'View or set active mission',
|
|
||||||
aliases: [],
|
|
||||||
scope: 'agent',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'help',
|
|
||||||
description: 'Show available commands',
|
|
||||||
aliases: ['h'],
|
|
||||||
scope: 'core',
|
|
||||||
execution: 'local',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'gc',
|
|
||||||
description: 'Trigger garbage collection sweep',
|
|
||||||
aliases: [],
|
|
||||||
scope: 'core',
|
|
||||||
execution: 'socket',
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it('returns all commands when query is empty', () => {
|
|
||||||
expect(filterCommands(commands, '')).toHaveLength(commands.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters by name prefix "mi" → mission only (not model, as "mi" not in model name or aliases)', () => {
|
|
||||||
const result = filterCommands(commands, 'mi');
|
|
||||||
const names = result.map((c) => c.name);
|
|
||||||
expect(names).toContain('mission');
|
|
||||||
expect(names).not.toContain('gc');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters by name prefix "mo" → model only', () => {
|
|
||||||
const result = filterCommands(commands, 'mo');
|
|
||||||
const names = result.map((c) => c.name);
|
|
||||||
expect(names).toContain('model');
|
|
||||||
expect(names).not.toContain('mission');
|
|
||||||
expect(names).not.toContain('gc');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters by exact name "gc" → gc only', () => {
|
|
||||||
const result = filterCommands(commands, 'gc');
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0]!.name).toBe('gc');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters by alias "h" → help', () => {
|
|
||||||
const result = filterCommands(commands, 'h');
|
|
||||||
const names = result.map((c) => c.name);
|
|
||||||
expect(names).toContain('help');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters by description keyword "switch" → model', () => {
|
|
||||||
const result = filterCommands(commands, 'switch');
|
|
||||||
const names = result.map((c) => c.name);
|
|
||||||
expect(names).toContain('model');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty array when no commands match', () => {
|
|
||||||
const result = filterCommands(commands, 'zzznotfound');
|
|
||||||
expect(result).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export { parseSlashCommand } from './parse.js';
|
|
||||||
export { commandRegistry, CommandRegistry } from './registry.js';
|
|
||||||
export { executeHelp } from './local/help.js';
|
|
||||||
export { executeStatus } from './local/status.js';
|
|
||||||
export type { StatusContext } from './local/status.js';
|
|
||||||
export { executeHistory } from './local/history.js';
|
|
||||||
export type { HistoryContext } from './local/history.js';
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { ParsedCommand } from '@mosaicstack/types';
|
|
||||||
import { commandRegistry } from '../registry.js';
|
|
||||||
|
|
||||||
export function executeHelp(_parsed: ParsedCommand): string {
|
|
||||||
const commands = commandRegistry.getAll();
|
|
||||||
const lines = ['Available commands:', ''];
|
|
||||||
|
|
||||||
for (const cmd of commands) {
|
|
||||||
const aliases =
|
|
||||||
cmd.aliases.length > 0 ? ` (${cmd.aliases.map((a) => `/${a}`).join(', ')})` : '';
|
|
||||||
const argsStr =
|
|
||||||
cmd.args && cmd.args.length > 0
|
|
||||||
? ' ' + cmd.args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ')
|
|
||||||
: '';
|
|
||||||
lines.push(` /${cmd.name}${argsStr}${aliases} — ${cmd.description}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join('\n').trimEnd();
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import type { ConversationMessage } from '../../gateway-api.js';
|
|
||||||
|
|
||||||
const CONTEXT_WINDOW = 200_000;
|
|
||||||
const CHARS_PER_TOKEN = 4;
|
|
||||||
|
|
||||||
function estimateTokens(messages: ConversationMessage[]): number {
|
|
||||||
const totalChars = messages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0);
|
|
||||||
return Math.round(totalChars / CHARS_PER_TOKEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistoryContext {
|
|
||||||
conversationId: string | undefined;
|
|
||||||
conversationTitle?: string | null;
|
|
||||||
gatewayUrl: string;
|
|
||||||
sessionCookie: string | undefined;
|
|
||||||
fetchMessages: (
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
conversationId: string,
|
|
||||||
) => Promise<ConversationMessage[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function executeHistory(ctx: HistoryContext): Promise<string> {
|
|
||||||
const { conversationId, conversationTitle, gatewayUrl, sessionCookie, fetchMessages } = ctx;
|
|
||||||
|
|
||||||
if (!conversationId) {
|
|
||||||
return 'No active conversation.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionCookie) {
|
|
||||||
return 'Not authenticated — cannot fetch conversation messages.';
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = await fetchMessages(gatewayUrl, sessionCookie, conversationId);
|
|
||||||
|
|
||||||
const userMessages = messages.filter((m) => m.role === 'user').length;
|
|
||||||
const assistantMessages = messages.filter((m) => m.role === 'assistant').length;
|
|
||||||
const totalMessages = messages.length;
|
|
||||||
|
|
||||||
const estimatedTokens = estimateTokens(messages);
|
|
||||||
const contextPercent = Math.round((estimatedTokens / CONTEXT_WINDOW) * 100);
|
|
||||||
|
|
||||||
const label = conversationTitle ?? conversationId;
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
`Conversation: ${label}`,
|
|
||||||
`Messages: ${totalMessages} (${userMessages} user, ${assistantMessages} assistant)`,
|
|
||||||
`Estimated tokens: ~${estimatedTokens.toLocaleString()}`,
|
|
||||||
`Context usage: ~${contextPercent}% of ${(CONTEXT_WINDOW / 1000).toFixed(0)}K`,
|
|
||||||
];
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { ParsedCommand } from '@mosaicstack/types';
|
|
||||||
|
|
||||||
export interface StatusContext {
|
|
||||||
connected: boolean;
|
|
||||||
model: string | null;
|
|
||||||
provider: string | null;
|
|
||||||
sessionId: string | null;
|
|
||||||
tokenCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function executeStatus(_parsed: ParsedCommand, ctx: StatusContext): string {
|
|
||||||
const lines = [
|
|
||||||
`Connection: ${ctx.connected ? 'connected' : 'disconnected'}`,
|
|
||||||
`Model: ${ctx.model ?? 'unknown'}`,
|
|
||||||
`Provider: ${ctx.provider ?? 'unknown'}`,
|
|
||||||
`Session: ${ctx.sessionId ?? 'none'}`,
|
|
||||||
`Tokens (session): ${ctx.tokenCount}`,
|
|
||||||
];
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { ParsedCommand } from '@mosaicstack/types';
|
|
||||||
|
|
||||||
export function parseSlashCommand(input: string): ParsedCommand | null {
|
|
||||||
const match = input.match(/^\/([a-z][a-z0-9:_-]*)\s*(.*)?$/i);
|
|
||||||
if (!match) return null;
|
|
||||||
return {
|
|
||||||
command: match[1]!,
|
|
||||||
args: match[2]?.trim() || null,
|
|
||||||
raw: input,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import type { CommandDef, CommandManifest } from '@mosaicstack/types';
|
|
||||||
|
|
||||||
// Local-only commands (work even when gateway is disconnected)
|
|
||||||
const LOCAL_COMMANDS: CommandDef[] = [
|
|
||||||
{
|
|
||||||
name: 'help',
|
|
||||||
description: 'Show available commands',
|
|
||||||
aliases: ['h'],
|
|
||||||
args: undefined,
|
|
||||||
execution: 'local',
|
|
||||||
available: true,
|
|
||||||
scope: 'core',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'stop',
|
|
||||||
description: 'Cancel current streaming response',
|
|
||||||
aliases: [],
|
|
||||||
args: undefined,
|
|
||||||
execution: 'local',
|
|
||||||
available: true,
|
|
||||||
scope: 'core',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'cost',
|
|
||||||
description: 'Show token usage and cost for current session',
|
|
||||||
aliases: [],
|
|
||||||
args: undefined,
|
|
||||||
execution: 'local',
|
|
||||||
available: true,
|
|
||||||
scope: 'core',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'status',
|
|
||||||
description: 'Show connection and session status',
|
|
||||||
aliases: ['s'],
|
|
||||||
args: undefined,
|
|
||||||
execution: 'local',
|
|
||||||
available: true,
|
|
||||||
scope: 'core',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'history',
|
|
||||||
description: 'Show conversation message count and context usage',
|
|
||||||
aliases: ['hist'],
|
|
||||||
args: undefined,
|
|
||||||
execution: 'local',
|
|
||||||
available: true,
|
|
||||||
scope: 'core',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'clear',
|
|
||||||
description: 'Clear the current conversation display',
|
|
||||||
aliases: [],
|
|
||||||
args: undefined,
|
|
||||||
execution: 'local',
|
|
||||||
available: true,
|
|
||||||
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',
|
|
||||||
description: 'Start a new conversation',
|
|
||||||
aliases: ['n'],
|
|
||||||
args: undefined,
|
|
||||||
execution: 'local',
|
|
||||||
available: true,
|
|
||||||
scope: 'core',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ALIASES: Record<string, string> = {
|
|
||||||
m: 'model',
|
|
||||||
t: 'thinking',
|
|
||||||
a: 'agent',
|
|
||||||
s: 'status',
|
|
||||||
h: 'help',
|
|
||||||
hist: 'history',
|
|
||||||
pref: 'preferences',
|
|
||||||
};
|
|
||||||
|
|
||||||
export class CommandRegistry {
|
|
||||||
private gatewayManifest: CommandManifest | null = null;
|
|
||||||
|
|
||||||
updateManifest(manifest: CommandManifest): void {
|
|
||||||
this.gatewayManifest = manifest;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveAlias(command: string): string {
|
|
||||||
return ALIASES[command] ?? command;
|
|
||||||
}
|
|
||||||
|
|
||||||
find(command: string): CommandDef | null {
|
|
||||||
const resolved = this.resolveAlias(command);
|
|
||||||
// Search local first, then gateway manifest
|
|
||||||
const local = LOCAL_COMMANDS.find((c) => c.name === resolved || c.aliases.includes(resolved));
|
|
||||||
if (local) return local;
|
|
||||||
if (this.gatewayManifest) {
|
|
||||||
return (
|
|
||||||
this.gatewayManifest.commands.find(
|
|
||||||
(c) => c.name === resolved || c.aliases.includes(resolved),
|
|
||||||
) ?? null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getAll(): CommandDef[] {
|
|
||||||
const gateway = this.gatewayManifest?.commands ?? [];
|
|
||||||
// Local commands take precedence; deduplicate gateway commands that share
|
|
||||||
// a name with a local command to avoid duplicate React keys and confusing
|
|
||||||
// autocomplete entries.
|
|
||||||
const localNames = new Set(LOCAL_COMMANDS.map((c) => c.name));
|
|
||||||
const dedupedGateway = gateway.filter((c) => !localNames.has(c.name));
|
|
||||||
return [...LOCAL_COMMANDS, ...dedupedGateway];
|
|
||||||
}
|
|
||||||
|
|
||||||
getLocalCommands(): CommandDef[] {
|
|
||||||
return LOCAL_COMMANDS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const commandRegistry = new CommandRegistry();
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Box, Text } from 'ink';
|
|
||||||
import type { RoutingDecisionInfo } from '@mosaicstack/types';
|
|
||||||
import type { TokenUsage } from '../hooks/use-socket.js';
|
|
||||||
import type { GitInfo } from '../hooks/use-git-info.js';
|
|
||||||
|
|
||||||
export interface BottomBarProps {
|
|
||||||
gitInfo: GitInfo;
|
|
||||||
tokenUsage: TokenUsage;
|
|
||||||
connected: boolean;
|
|
||||||
connecting: boolean;
|
|
||||||
modelName: string | null;
|
|
||||||
providerName: string | null;
|
|
||||||
thinkingLevel: string;
|
|
||||||
conversationId: string | undefined;
|
|
||||||
/** Routing decision info for transparency display (M4-008) */
|
|
||||||
routingDecision?: RoutingDecisionInfo | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTokens(n: number): string {
|
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
||||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
|
||||||
return String(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compact the cwd — replace home with ~ */
|
|
||||||
function compactCwd(cwd: string): string {
|
|
||||||
const home = process.env['HOME'] ?? '';
|
|
||||||
if (home && cwd.startsWith(home)) {
|
|
||||||
return '~' + cwd.slice(home.length);
|
|
||||||
}
|
|
||||||
return cwd;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BottomBar({
|
|
||||||
gitInfo,
|
|
||||||
tokenUsage,
|
|
||||||
connected,
|
|
||||||
connecting,
|
|
||||||
modelName,
|
|
||||||
providerName,
|
|
||||||
thinkingLevel,
|
|
||||||
conversationId,
|
|
||||||
routingDecision,
|
|
||||||
}: BottomBarProps) {
|
|
||||||
const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected';
|
|
||||||
const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
|
||||||
|
|
||||||
const hasTokens = tokenUsage.total > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column" paddingX={0} marginTop={0}>
|
|
||||||
{/* Line 0: keybinding hints */}
|
|
||||||
<Box>
|
|
||||||
<Text dimColor>^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Line 1: blank ····· Gateway: Status */}
|
|
||||||
<Box justifyContent="space-between">
|
|
||||||
<Box />
|
|
||||||
<Box>
|
|
||||||
<Text dimColor>Gateway: </Text>
|
|
||||||
<Text color={gatewayColor}>{gatewayStatus}</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Line 2: cwd (branch) ····· Session: id */}
|
|
||||||
<Box justifyContent="space-between">
|
|
||||||
<Box>
|
|
||||||
<Text dimColor>{compactCwd(gitInfo.cwd)}</Text>
|
|
||||||
{gitInfo.branch && <Text dimColor> ({gitInfo.branch})</Text>}
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Text dimColor>
|
|
||||||
{conversationId ? `Session: ${conversationId.slice(0, 8)}` : 'No session'}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Line 3: token stats ····· (provider) model */}
|
|
||||||
<Box justifyContent="space-between" minHeight={1}>
|
|
||||||
<Box>
|
|
||||||
{hasTokens ? (
|
|
||||||
<>
|
|
||||||
<Text dimColor>↑{formatTokens(tokenUsage.input)}</Text>
|
|
||||||
<Text dimColor>{' '}</Text>
|
|
||||||
<Text dimColor>↓{formatTokens(tokenUsage.output)}</Text>
|
|
||||||
{tokenUsage.cacheRead > 0 && (
|
|
||||||
<>
|
|
||||||
<Text dimColor>{' '}</Text>
|
|
||||||
<Text dimColor>R{formatTokens(tokenUsage.cacheRead)}</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{tokenUsage.cacheWrite > 0 && (
|
|
||||||
<>
|
|
||||||
<Text dimColor>{' '}</Text>
|
|
||||||
<Text dimColor>W{formatTokens(tokenUsage.cacheWrite)}</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{tokenUsage.cost > 0 && (
|
|
||||||
<>
|
|
||||||
<Text dimColor>{' '}</Text>
|
|
||||||
<Text dimColor>${tokenUsage.cost.toFixed(3)}</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{tokenUsage.contextPercent > 0 && (
|
|
||||||
<>
|
|
||||||
<Text dimColor>{' '}</Text>
|
|
||||||
<Text dimColor>
|
|
||||||
{tokenUsage.contextPercent.toFixed(1)}%/{formatTokens(tokenUsage.contextWindow)}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Text dimColor>↑0 ↓0 $0.000</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Text dimColor>
|
|
||||||
{providerName ? `(${providerName}) ` : ''}
|
|
||||||
{modelName ?? 'awaiting model'}
|
|
||||||
{thinkingLevel !== 'off' ? ` • ${thinkingLevel}` : ''}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Line 4: routing transparency (M4-008) — only shown when a routing decision is available */}
|
|
||||||
{routingDecision && (
|
|
||||||
<Box>
|
|
||||||
<Text dimColor>
|
|
||||||
Routed: {routingDecision.model} ({routingDecision.reason})
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Box, Text } from 'ink';
|
|
||||||
import type { CommandDef, CommandArgDef } from '@mosaicstack/types';
|
|
||||||
|
|
||||||
interface CommandAutocompleteProps {
|
|
||||||
commands: CommandDef[];
|
|
||||||
selectedIndex: number;
|
|
||||||
inputValue: string; // the current input after '/'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommandAutocomplete({
|
|
||||||
commands,
|
|
||||||
selectedIndex,
|
|
||||||
inputValue,
|
|
||||||
}: CommandAutocompleteProps) {
|
|
||||||
if (commands.length === 0) return null;
|
|
||||||
|
|
||||||
// Filter by inputValue prefix/fuzzy match
|
|
||||||
const query = inputValue.startsWith('/') ? inputValue.slice(1) : inputValue;
|
|
||||||
const filtered = filterCommands(commands, query);
|
|
||||||
|
|
||||||
if (filtered.length === 0) return null;
|
|
||||||
|
|
||||||
const clampedIndex = Math.min(selectedIndex, filtered.length - 1);
|
|
||||||
const selected = filtered[clampedIndex];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
|
||||||
{filtered.slice(0, 8).map((cmd, i) => (
|
|
||||||
<Box key={`${cmd.execution}-${cmd.name}`}>
|
|
||||||
<Text color={i === clampedIndex ? 'cyan' : 'white'} bold={i === clampedIndex}>
|
|
||||||
{i === clampedIndex ? '▶ ' : ' '}/{cmd.name}
|
|
||||||
</Text>
|
|
||||||
{cmd.aliases.length > 0 && (
|
|
||||||
<Text color="gray"> ({cmd.aliases.map((a) => `/${a}`).join(', ')})</Text>
|
|
||||||
)}
|
|
||||||
<Text color="gray"> — {cmd.description}</Text>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
{selected && selected.args && selected.args.length > 0 && (
|
|
||||||
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
|
|
||||||
<Text color="yellow">
|
|
||||||
/{selected.name} {getArgHint(selected.args)}
|
|
||||||
</Text>
|
|
||||||
<Text color="gray"> — {selected.description}</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterCommands(commands: CommandDef[], query: string): CommandDef[] {
|
|
||||||
if (!query) return commands;
|
|
||||||
const q = query.toLowerCase();
|
|
||||||
return commands.filter(
|
|
||||||
(c) =>
|
|
||||||
c.name.includes(q) ||
|
|
||||||
c.aliases.some((a) => a.includes(q)) ||
|
|
||||||
c.description.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getArgHint(args: CommandArgDef[]): string {
|
|
||||||
if (!args || args.length === 0) return '';
|
|
||||||
return args.map((a) => (a.optional ? `[${a.name}]` : `<${a.name}>`)).join(' ');
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
import React, { useCallback } from 'react';
|
|
||||||
import { Box, Text, useInput } from 'ink';
|
|
||||||
import TextInput from 'ink-text-input';
|
|
||||||
import type { ParsedCommand, CommandDef } from '@mosaicstack/types';
|
|
||||||
import { parseSlashCommand, commandRegistry } from '../commands/index.js';
|
|
||||||
import { CommandAutocomplete } from './command-autocomplete.js';
|
|
||||||
import { useInputHistory } from '../hooks/use-input-history.js';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
export interface InputBarProps {
|
|
||||||
/** Controlled input value — caller owns the state */
|
|
||||||
value: string;
|
|
||||||
onChange: (val: string) => void;
|
|
||||||
onSubmit: (value: string) => void;
|
|
||||||
onSystemMessage?: (message: string) => void;
|
|
||||||
onLocalCommand?: (parsed: ParsedCommand) => void;
|
|
||||||
onGatewayCommand?: (parsed: ParsedCommand) => void;
|
|
||||||
isStreaming: boolean;
|
|
||||||
connected: boolean;
|
|
||||||
/** Whether this input bar is focused/active (default true). When false,
|
|
||||||
* keyboard input is not captured — e.g. when the sidebar has focus. */
|
|
||||||
focused?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
allCommands?: CommandDef[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InputBar({
|
|
||||||
value: input,
|
|
||||||
onChange: setInput,
|
|
||||||
onSubmit,
|
|
||||||
onSystemMessage,
|
|
||||||
onLocalCommand,
|
|
||||||
onGatewayCommand,
|
|
||||||
isStreaming,
|
|
||||||
connected,
|
|
||||||
focused = true,
|
|
||||||
placeholder: placeholderOverride,
|
|
||||||
allCommands,
|
|
||||||
}: InputBarProps) {
|
|
||||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
|
||||||
const [autocompleteIndex, setAutocompleteIndex] = useState(0);
|
|
||||||
|
|
||||||
const { addToHistory, navigateUp, navigateDown } = useInputHistory();
|
|
||||||
|
|
||||||
// Determine which commands to show in autocomplete
|
|
||||||
const availableCommands = allCommands ?? commandRegistry.getAll();
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setInput(value);
|
|
||||||
if (value.startsWith('/')) {
|
|
||||||
setShowAutocomplete(true);
|
|
||||||
setAutocompleteIndex(0);
|
|
||||||
} else {
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setInput],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
if (!value.trim() || isStreaming || !connected) return;
|
|
||||||
|
|
||||||
const trimmed = value.trim();
|
|
||||||
|
|
||||||
addToHistory(trimmed);
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
setAutocompleteIndex(0);
|
|
||||||
|
|
||||||
if (trimmed.startsWith('/')) {
|
|
||||||
const parsed = parseSlashCommand(trimmed);
|
|
||||||
if (!parsed) {
|
|
||||||
// Bare "/" or malformed — ignore silently (autocomplete handles discovery)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const def = commandRegistry.find(parsed.command);
|
|
||||||
if (!def) {
|
|
||||||
onSystemMessage?.(
|
|
||||||
`Unknown command: /${parsed.command}. Type /help for available commands.`,
|
|
||||||
);
|
|
||||||
setInput('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (def.execution === 'local') {
|
|
||||||
onLocalCommand?.(parsed);
|
|
||||||
setInput('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Gateway-executed commands
|
|
||||||
onGatewayCommand?.(parsed);
|
|
||||||
setInput('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit(value);
|
|
||||||
setInput('');
|
|
||||||
},
|
|
||||||
[
|
|
||||||
onSubmit,
|
|
||||||
onSystemMessage,
|
|
||||||
onLocalCommand,
|
|
||||||
onGatewayCommand,
|
|
||||||
isStreaming,
|
|
||||||
connected,
|
|
||||||
addToHistory,
|
|
||||||
setInput,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle Tab: fill in selected autocomplete command
|
|
||||||
const fillAutocompleteSelection = useCallback(() => {
|
|
||||||
if (!showAutocomplete) return false;
|
|
||||||
const query = input.startsWith('/') ? input.slice(1) : input;
|
|
||||||
const filtered = availableCommands.filter(
|
|
||||||
(c) =>
|
|
||||||
!query ||
|
|
||||||
c.name.includes(query.toLowerCase()) ||
|
|
||||||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
|
||||||
c.description.toLowerCase().includes(query.toLowerCase()),
|
|
||||||
);
|
|
||||||
if (filtered.length === 0) return false;
|
|
||||||
const idx = Math.min(autocompleteIndex, filtered.length - 1);
|
|
||||||
const selected = filtered[idx];
|
|
||||||
if (selected) {
|
|
||||||
setInput(`/${selected.name} `);
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
setAutocompleteIndex(0);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}, [showAutocomplete, input, availableCommands, autocompleteIndex, setInput]);
|
|
||||||
|
|
||||||
useInput(
|
|
||||||
(_ch, key) => {
|
|
||||||
if (key.escape && showAutocomplete) {
|
|
||||||
setShowAutocomplete(false);
|
|
||||||
setAutocompleteIndex(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab: fill autocomplete selection
|
|
||||||
if (key.tab) {
|
|
||||||
fillAutocompleteSelection();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Up arrow
|
|
||||||
if (key.upArrow) {
|
|
||||||
if (showAutocomplete) {
|
|
||||||
setAutocompleteIndex((prev) => Math.max(0, prev - 1));
|
|
||||||
} else {
|
|
||||||
const prev = navigateUp(input);
|
|
||||||
if (prev !== null) {
|
|
||||||
setInput(prev);
|
|
||||||
if (prev.startsWith('/')) setShowAutocomplete(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Down arrow
|
|
||||||
if (key.downArrow) {
|
|
||||||
if (showAutocomplete) {
|
|
||||||
const query = input.startsWith('/') ? input.slice(1) : input;
|
|
||||||
const filteredLen = availableCommands.filter(
|
|
||||||
(c) =>
|
|
||||||
!query ||
|
|
||||||
c.name.includes(query.toLowerCase()) ||
|
|
||||||
c.aliases.some((a) => a.includes(query.toLowerCase())) ||
|
|
||||||
c.description.toLowerCase().includes(query.toLowerCase()),
|
|
||||||
).length;
|
|
||||||
const maxVisible = Math.min(filteredLen, 8);
|
|
||||||
setAutocompleteIndex((prev) => Math.min(prev + 1, maxVisible - 1));
|
|
||||||
} else {
|
|
||||||
const next = navigateDown();
|
|
||||||
if (next !== null) {
|
|
||||||
setInput(next);
|
|
||||||
setShowAutocomplete(next.startsWith('/'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return/Enter on autocomplete: fill selected command
|
|
||||||
if (key.return && showAutocomplete) {
|
|
||||||
fillAutocompleteSelection();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ isActive: focused },
|
|
||||||
);
|
|
||||||
|
|
||||||
const placeholder =
|
|
||||||
placeholderOverride ??
|
|
||||||
(!connected
|
|
||||||
? 'disconnected — waiting for gateway…'
|
|
||||||
: isStreaming
|
|
||||||
? 'waiting for response…'
|
|
||||||
: 'message mosaic…');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column">
|
|
||||||
{showAutocomplete && (
|
|
||||||
<CommandAutocomplete
|
|
||||||
commands={availableCommands}
|
|
||||||
selectedIndex={autocompleteIndex}
|
|
||||||
inputValue={input}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Box paddingX={1} borderStyle="single" borderColor="gray">
|
|
||||||
<Text bold color="green">
|
|
||||||
{'❯ '}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={input}
|
|
||||||
onChange={handleChange}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
placeholder={placeholder}
|
|
||||||
focus={focused}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Box, Text } from 'ink';
|
|
||||||
import Spinner from 'ink-spinner';
|
|
||||||
import type { Message, ToolCall } from '../hooks/use-socket.js';
|
|
||||||
|
|
||||||
export interface MessageListProps {
|
|
||||||
messages: Message[];
|
|
||||||
isStreaming: boolean;
|
|
||||||
currentStreamText: string;
|
|
||||||
currentThinkingText: string;
|
|
||||||
activeToolCalls: ToolCall[];
|
|
||||||
scrollOffset?: number;
|
|
||||||
viewportSize?: number;
|
|
||||||
isScrolledUp?: boolean;
|
|
||||||
highlightedMessageIndices?: Set<number>;
|
|
||||||
currentHighlightIndex?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(date: Date): string {
|
|
||||||
return date.toLocaleTimeString('en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function SystemMessageBubble({ msg }: { msg: Message }) {
|
|
||||||
return (
|
|
||||||
<Box flexDirection="row" marginBottom={1} marginLeft={2}>
|
|
||||||
<Text dimColor>{'⚙ '}</Text>
|
|
||||||
<Text dimColor wrap="wrap">
|
|
||||||
{msg.content}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageBubble({
|
|
||||||
msg,
|
|
||||||
highlight,
|
|
||||||
}: {
|
|
||||||
msg: Message;
|
|
||||||
highlight?: 'match' | 'current' | undefined;
|
|
||||||
}) {
|
|
||||||
if (msg.role === 'system') {
|
|
||||||
return <SystemMessageBubble msg={msg} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isUser = msg.role === 'user';
|
|
||||||
const prefix = isUser ? '❯' : '◆';
|
|
||||||
const color = isUser ? 'green' : 'cyan';
|
|
||||||
|
|
||||||
const borderIndicator =
|
|
||||||
highlight === 'current' ? (
|
|
||||||
<Text color="yellowBright" bold>
|
|
||||||
▌{' '}
|
|
||||||
</Text>
|
|
||||||
) : highlight === 'match' ? (
|
|
||||||
<Text color="yellow">▌ </Text>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection="row" marginBottom={1}>
|
|
||||||
{borderIndicator}
|
|
||||||
<Box flexDirection="column">
|
|
||||||
<Box>
|
|
||||||
<Text bold color={color}>
|
|
||||||
{prefix}{' '}
|
|
||||||
</Text>
|
|
||||||
<Text bold color={color}>
|
|
||||||
{isUser ? 'you' : 'assistant'}
|
|
||||||
</Text>
|
|
||||||
<Text dimColor> {formatTime(msg.timestamp)}</Text>
|
|
||||||
</Box>
|
|
||||||
<Box marginLeft={2}>
|
|
||||||
<Text wrap="wrap">{msg.content}</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolCallIndicator({ toolCall }: { toolCall: ToolCall }) {
|
|
||||||
const icon = toolCall.status === 'running' ? null : toolCall.status === 'success' ? '✓' : '✗';
|
|
||||||
const color =
|
|
||||||
toolCall.status === 'running' ? 'yellow' : toolCall.status === 'success' ? 'green' : 'red';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box marginLeft={2}>
|
|
||||||
{toolCall.status === 'running' ? (
|
|
||||||
<Text color="yellow">
|
|
||||||
<Spinner type="dots" />
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text color={color}>{icon}</Text>
|
|
||||||
)}
|
|
||||||
<Text dimColor> tool: </Text>
|
|
||||||
<Text color={color}>{toolCall.toolName}</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessageList({
|
|
||||||
messages,
|
|
||||||
isStreaming,
|
|
||||||
currentStreamText,
|
|
||||||
currentThinkingText,
|
|
||||||
activeToolCalls,
|
|
||||||
scrollOffset,
|
|
||||||
viewportSize,
|
|
||||||
isScrolledUp,
|
|
||||||
highlightedMessageIndices,
|
|
||||||
currentHighlightIndex,
|
|
||||||
}: MessageListProps) {
|
|
||||||
const useSlicing = scrollOffset != null && viewportSize != null;
|
|
||||||
const visibleMessages = useSlicing
|
|
||||||
? messages.slice(scrollOffset, scrollOffset + viewportSize)
|
|
||||||
: messages;
|
|
||||||
const hiddenAbove = useSlicing ? scrollOffset : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
|
||||||
{isScrolledUp && hiddenAbove > 0 && (
|
|
||||||
<Box justifyContent="center">
|
|
||||||
<Text dimColor>↑ {hiddenAbove} more messages ↑</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{messages.length === 0 && !isStreaming && (
|
|
||||||
<Box justifyContent="center" marginY={1}>
|
|
||||||
<Text dimColor>No messages yet. Type below to start a conversation.</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{visibleMessages.map((msg, i) => {
|
|
||||||
const globalIndex = hiddenAbove + i;
|
|
||||||
const highlight =
|
|
||||||
globalIndex === currentHighlightIndex
|
|
||||||
? ('current' as const)
|
|
||||||
: highlightedMessageIndices?.has(globalIndex)
|
|
||||||
? ('match' as const)
|
|
||||||
: undefined;
|
|
||||||
return <MessageBubble key={globalIndex} msg={msg} highlight={highlight} />;
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Active thinking */}
|
|
||||||
{isStreaming && currentThinkingText && (
|
|
||||||
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
|
|
||||||
<Text dimColor italic>
|
|
||||||
💭 {currentThinkingText}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Active tool calls */}
|
|
||||||
{activeToolCalls.length > 0 && (
|
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
|
||||||
{activeToolCalls.map((tc) => (
|
|
||||||
<ToolCallIndicator key={tc.toolCallId} toolCall={tc} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Streaming response */}
|
|
||||||
{isStreaming && currentStreamText && (
|
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
|
||||||
<Box>
|
|
||||||
<Text bold color="cyan">
|
|
||||||
◆{' '}
|
|
||||||
</Text>
|
|
||||||
<Text bold color="cyan">
|
|
||||||
assistant
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box marginLeft={2}>
|
|
||||||
<Text wrap="wrap">{currentStreamText}</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Waiting spinner */}
|
|
||||||
{isStreaming && !currentStreamText && activeToolCalls.length === 0 && (
|
|
||||||
<Box marginLeft={2}>
|
|
||||||
<Text color="cyan">
|
|
||||||
<Spinner type="dots" />
|
|
||||||
</Text>
|
|
||||||
<Text dimColor> thinking…</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Box, Text, useInput } from 'ink';
|
|
||||||
import TextInput from 'ink-text-input';
|
|
||||||
|
|
||||||
export interface SearchBarProps {
|
|
||||||
query: string;
|
|
||||||
onQueryChange: (q: string) => void;
|
|
||||||
totalMatches: number;
|
|
||||||
currentMatch: number;
|
|
||||||
onNext: () => void;
|
|
||||||
onPrev: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
focused: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchBar({
|
|
||||||
query,
|
|
||||||
onQueryChange,
|
|
||||||
totalMatches,
|
|
||||||
currentMatch,
|
|
||||||
onNext,
|
|
||||||
onPrev,
|
|
||||||
onClose,
|
|
||||||
focused,
|
|
||||||
}: SearchBarProps) {
|
|
||||||
useInput(
|
|
||||||
(_input, key) => {
|
|
||||||
if (key.upArrow) {
|
|
||||||
onPrev();
|
|
||||||
}
|
|
||||||
if (key.downArrow) {
|
|
||||||
onNext();
|
|
||||||
}
|
|
||||||
if (key.escape) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ isActive: focused },
|
|
||||||
);
|
|
||||||
|
|
||||||
const borderColor = focused ? 'yellow' : 'gray';
|
|
||||||
|
|
||||||
const matchDisplay =
|
|
||||||
query.length >= 2
|
|
||||||
? totalMatches > 0
|
|
||||||
? `${String(currentMatch + 1)}/${String(totalMatches)}`
|
|
||||||
: 'no matches'
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="row" gap={1}>
|
|
||||||
<Text>🔍</Text>
|
|
||||||
<Box flexGrow={1}>
|
|
||||||
<TextInput value={query} onChange={onQueryChange} focus={focused} />
|
|
||||||
</Box>
|
|
||||||
{matchDisplay && <Text dimColor>{matchDisplay}</Text>}
|
|
||||||
<Text dimColor>↑↓ navigate · Esc close</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Box, Text, useInput } from 'ink';
|
|
||||||
import type { ConversationSummary } from '../hooks/use-conversations.js';
|
|
||||||
|
|
||||||
export interface SidebarProps {
|
|
||||||
conversations: ConversationSummary[];
|
|
||||||
activeConversationId: string | undefined;
|
|
||||||
selectedIndex: number;
|
|
||||||
onSelectIndex: (index: number) => void;
|
|
||||||
onSwitchConversation: (id: string) => void;
|
|
||||||
onDeleteConversation: (id: string) => void;
|
|
||||||
loading: boolean;
|
|
||||||
focused: boolean;
|
|
||||||
width: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelativeTime(dateStr: string): string {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (diffDays === 0) {
|
|
||||||
const hh = String(date.getHours()).padStart(2, '0');
|
|
||||||
const mm = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
return `${hh}:${mm}`;
|
|
||||||
}
|
|
||||||
if (diffDays < 7) {
|
|
||||||
return `${diffDays}d ago`;
|
|
||||||
}
|
|
||||||
const months = [
|
|
||||||
'Jan',
|
|
||||||
'Feb',
|
|
||||||
'Mar',
|
|
||||||
'Apr',
|
|
||||||
'May',
|
|
||||||
'Jun',
|
|
||||||
'Jul',
|
|
||||||
'Aug',
|
|
||||||
'Sep',
|
|
||||||
'Oct',
|
|
||||||
'Nov',
|
|
||||||
'Dec',
|
|
||||||
];
|
|
||||||
const mon = months[date.getMonth()];
|
|
||||||
const dd = String(date.getDate()).padStart(2, '0');
|
|
||||||
return `${mon} ${dd}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncate(text: string, maxLen: number): string {
|
|
||||||
if (text.length <= maxLen) return text;
|
|
||||||
return text.slice(0, maxLen - 1) + '…';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Sidebar({
|
|
||||||
conversations,
|
|
||||||
activeConversationId,
|
|
||||||
selectedIndex,
|
|
||||||
onSelectIndex,
|
|
||||||
onSwitchConversation,
|
|
||||||
onDeleteConversation,
|
|
||||||
loading,
|
|
||||||
focused,
|
|
||||||
width,
|
|
||||||
}: SidebarProps) {
|
|
||||||
useInput(
|
|
||||||
(_input, key) => {
|
|
||||||
if (key.upArrow) {
|
|
||||||
onSelectIndex(Math.max(0, selectedIndex - 1));
|
|
||||||
}
|
|
||||||
if (key.downArrow) {
|
|
||||||
onSelectIndex(Math.min(conversations.length - 1, selectedIndex + 1));
|
|
||||||
}
|
|
||||||
if (key.return) {
|
|
||||||
const conv = conversations[selectedIndex];
|
|
||||||
if (conv) {
|
|
||||||
onSwitchConversation(conv.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (_input === 'd') {
|
|
||||||
const conv = conversations[selectedIndex];
|
|
||||||
if (conv) {
|
|
||||||
onDeleteConversation(conv.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ isActive: focused },
|
|
||||||
);
|
|
||||||
|
|
||||||
const borderColor = focused ? 'cyan' : 'gray';
|
|
||||||
// Available width for content inside border + padding
|
|
||||||
const innerWidth = width - 4; // 2 border + 2 padding
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
flexDirection="column"
|
|
||||||
width={width}
|
|
||||||
borderStyle="single"
|
|
||||||
borderColor={borderColor}
|
|
||||||
paddingX={1}
|
|
||||||
>
|
|
||||||
<Text bold color="cyan">
|
|
||||||
Conversations
|
|
||||||
</Text>
|
|
||||||
<Box marginTop={0} flexDirection="column" flexGrow={1}>
|
|
||||||
{loading && conversations.length === 0 ? (
|
|
||||||
<Text dimColor>Loading…</Text>
|
|
||||||
) : conversations.length === 0 ? (
|
|
||||||
<Text dimColor>No conversations</Text>
|
|
||||||
) : (
|
|
||||||
conversations.map((conv, idx) => {
|
|
||||||
const isActive = conv.id === activeConversationId;
|
|
||||||
const isSelected = idx === selectedIndex && focused;
|
|
||||||
const marker = isActive ? '● ' : ' ';
|
|
||||||
const time = formatRelativeTime(conv.updatedAt);
|
|
||||||
const title = conv.title ?? 'Untitled';
|
|
||||||
// marker(2) + title + space(1) + time
|
|
||||||
const maxTitleLen = Math.max(4, innerWidth - marker.length - time.length - 1);
|
|
||||||
const displayTitle = truncate(title, maxTitleLen);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={conv.id}>
|
|
||||||
<Text
|
|
||||||
inverse={isSelected}
|
|
||||||
color={isActive ? 'cyan' : undefined}
|
|
||||||
dimColor={!isActive && !isSelected}
|
|
||||||
>
|
|
||||||
{marker}
|
|
||||||
{displayTitle}
|
|
||||||
{' '.repeat(
|
|
||||||
Math.max(0, innerWidth - marker.length - displayTitle.length - time.length),
|
|
||||||
)}
|
|
||||||
{time}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{focused && <Text dimColor>↑↓ navigate • enter switch • d delete</Text>}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Box, Text } from 'ink';
|
|
||||||
|
|
||||||
export interface TopBarProps {
|
|
||||||
gatewayUrl: string;
|
|
||||||
version: string;
|
|
||||||
modelName: string | null;
|
|
||||||
thinkingLevel: string;
|
|
||||||
contextWindow: number;
|
|
||||||
agentName: string;
|
|
||||||
connected: boolean;
|
|
||||||
connecting: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compact the URL — strip protocol */
|
|
||||||
function compactHost(url: string): string {
|
|
||||||
return url.replace(/^https?:\/\//, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatContextWindow(n: number): string {
|
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
|
|
||||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
|
||||||
return String(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mosaic 3×3 icon — brand tiles with black gaps (windmill cross pattern)
|
|
||||||
*
|
|
||||||
* Layout:
|
|
||||||
* blue ·· purple
|
|
||||||
* ·· pink ··
|
|
||||||
* amber ·· teal
|
|
||||||
*/
|
|
||||||
// Two-space gap between tiles (extracted to avoid prettier collapse)
|
|
||||||
const GAP = ' ';
|
|
||||||
|
|
||||||
function MosaicIcon() {
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column" marginRight={2}>
|
|
||||||
<Text>
|
|
||||||
<Text color="#2f80ff">██</Text>
|
|
||||||
<Text>{GAP}</Text>
|
|
||||||
<Text color="#8b5cf6">██</Text>
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
<Text>{GAP}</Text>
|
|
||||||
<Text color="#ec4899">██</Text>
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
<Text color="#f59e0b">██</Text>
|
|
||||||
<Text>{GAP}</Text>
|
|
||||||
<Text color="#14b8a6">██</Text>
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TopBar({
|
|
||||||
gatewayUrl,
|
|
||||||
version,
|
|
||||||
modelName,
|
|
||||||
thinkingLevel,
|
|
||||||
contextWindow,
|
|
||||||
agentName,
|
|
||||||
connected,
|
|
||||||
connecting,
|
|
||||||
}: TopBarProps) {
|
|
||||||
const host = compactHost(gatewayUrl);
|
|
||||||
const connectionIndicator = connected ? '●' : '○';
|
|
||||||
const connectionColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
|
||||||
|
|
||||||
// Build model description line like: "claude-opus-4-6 (1M context) · default"
|
|
||||||
const modelDisplay = modelName ?? 'awaiting model';
|
|
||||||
const contextStr = contextWindow > 0 ? ` (${formatContextWindow(contextWindow)} context)` : '';
|
|
||||||
const thinkingStr = thinkingLevel !== 'off' ? ` · ${thinkingLevel}` : '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box paddingX={1} paddingY={0} marginBottom={1}>
|
|
||||||
<MosaicIcon />
|
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
|
||||||
<Text>
|
|
||||||
<Text bold color="#56a0ff">
|
|
||||||
Mosaic Stack
|
|
||||||
</Text>
|
|
||||||
<Text dimColor> v{version}</Text>
|
|
||||||
</Text>
|
|
||||||
<Text dimColor>
|
|
||||||
{modelDisplay}
|
|
||||||
{contextStr}
|
|
||||||
{thinkingStr} · {agentName}
|
|
||||||
</Text>
|
|
||||||
<Text>
|
|
||||||
<Text color={connectionColor}>{connectionIndicator}</Text>
|
|
||||||
<Text dimColor> {host}</Text>
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,438 +0,0 @@
|
|||||||
/**
|
|
||||||
* Minimal gateway REST API client for the TUI and CLI commands.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ModelInfo {
|
|
||||||
id: string;
|
|
||||||
provider: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProviderInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
available: boolean;
|
|
||||||
models: ModelInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionInfo {
|
|
||||||
id: string;
|
|
||||||
provider: string;
|
|
||||||
modelId: string;
|
|
||||||
createdAt: string;
|
|
||||||
promptCount: number;
|
|
||||||
channels: string[];
|
|
||||||
durationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionListResult {
|
|
||||||
sessions: SessionInfo[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Agent Config types ──
|
|
||||||
|
|
||||||
export interface AgentConfigInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
status: string;
|
|
||||||
projectId: string | null;
|
|
||||||
ownerId: string | null;
|
|
||||||
systemPrompt: string | null;
|
|
||||||
allowedTools: string[] | null;
|
|
||||||
skills: string[] | null;
|
|
||||||
isSystem: boolean;
|
|
||||||
config: Record<string, unknown> | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Project types ──
|
|
||||||
|
|
||||||
export interface ProjectInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
status: string;
|
|
||||||
ownerId: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mission types ──
|
|
||||||
|
|
||||||
export interface MissionInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
status: string;
|
|
||||||
projectId: string | null;
|
|
||||||
userId: string | null;
|
|
||||||
phase: string | null;
|
|
||||||
milestones: Record<string, unknown>[] | null;
|
|
||||||
config: Record<string, unknown> | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mission Task types ──
|
|
||||||
|
|
||||||
export interface MissionTaskInfo {
|
|
||||||
id: string;
|
|
||||||
missionId: string;
|
|
||||||
taskId: string | null;
|
|
||||||
userId: string;
|
|
||||||
status: string;
|
|
||||||
description: string | null;
|
|
||||||
notes: string | null;
|
|
||||||
pr: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ──
|
|
||||||
|
|
||||||
function headers(sessionCookie: string, gatewayUrl: string) {
|
|
||||||
return { Cookie: sessionCookie, Origin: gatewayUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonHeaders(sessionCookie: string, gatewayUrl: string) {
|
|
||||||
return { ...headers(sessionCookie, gatewayUrl), 'Content-Type': 'application/json' };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResponse<T>(res: Response, errorPrefix: string): Promise<T> {
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.text().catch(() => '');
|
|
||||||
throw new Error(`${errorPrefix} (${res.status}): ${body}`);
|
|
||||||
}
|
|
||||||
return (await res.json()) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Conversation types ──
|
|
||||||
|
|
||||||
export interface ConversationInfo {
|
|
||||||
id: string;
|
|
||||||
title: string | null;
|
|
||||||
archived: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Conversation endpoints ──
|
|
||||||
|
|
||||||
export async function createConversation(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
data: { title?: string; projectId?: string } = {},
|
|
||||||
): Promise<ConversationInfo> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/conversations`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
return handleResponse<ConversationInfo>(res, 'Failed to create conversation');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Provider / Model endpoints ──
|
|
||||||
|
|
||||||
export async function fetchAvailableModels(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie?: string,
|
|
||||||
): Promise<ModelInfo[]> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/providers/models`, {
|
|
||||||
headers: {
|
|
||||||
...(sessionCookie ? { Cookie: sessionCookie } : {}),
|
|
||||||
Origin: gatewayUrl,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = (await res.json()) as ModelInfo[];
|
|
||||||
return Array.isArray(data) ? data : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchProviders(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie?: string,
|
|
||||||
): Promise<ProviderInfo[]> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/providers`, {
|
|
||||||
headers: {
|
|
||||||
...(sessionCookie ? { Cookie: sessionCookie } : {}),
|
|
||||||
Origin: gatewayUrl,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = (await res.json()) as ProviderInfo[];
|
|
||||||
return Array.isArray(data) ? data : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Session endpoints ──
|
|
||||||
|
|
||||||
export async function fetchSessions(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
): Promise<SessionListResult> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/sessions`, {
|
|
||||||
headers: headers(sessionCookie, gatewayUrl),
|
|
||||||
});
|
|
||||||
return handleResponse<SessionListResult>(res, 'Failed to list sessions');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteSession(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
sessionId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: headers(sessionCookie, gatewayUrl),
|
|
||||||
});
|
|
||||||
if (!res.ok && res.status !== 204) {
|
|
||||||
const body = await res.text().catch(() => '');
|
|
||||||
throw new Error(`Failed to destroy session (${res.status}): ${body}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Agent Config endpoints ──
|
|
||||||
|
|
||||||
export async function fetchAgentConfigs(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
): Promise<AgentConfigInfo[]> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/agents`, {
|
|
||||||
headers: headers(sessionCookie, gatewayUrl),
|
|
||||||
});
|
|
||||||
return handleResponse<AgentConfigInfo[]>(res, 'Failed to list agents');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchAgentConfig(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
id: string,
|
|
||||||
): Promise<AgentConfigInfo> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
|
||||||
headers: headers(sessionCookie, gatewayUrl),
|
|
||||||
});
|
|
||||||
return handleResponse<AgentConfigInfo>(res, 'Failed to get agent');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createAgentConfig(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
projectId?: string;
|
|
||||||
systemPrompt?: string;
|
|
||||||
allowedTools?: string[];
|
|
||||||
skills?: string[];
|
|
||||||
config?: Record<string, unknown>;
|
|
||||||
},
|
|
||||||
): Promise<AgentConfigInfo> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/agents`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
return handleResponse<AgentConfigInfo>(res, 'Failed to create agent');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateAgentConfig(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
id: string,
|
|
||||||
data: Record<string, unknown>,
|
|
||||||
): Promise<AgentConfigInfo> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
return handleResponse<AgentConfigInfo>(res, 'Failed to update agent');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteAgentConfig(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
id: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/agents/${encodeURIComponent(id)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: headers(sessionCookie, gatewayUrl),
|
|
||||||
});
|
|
||||||
if (!res.ok && res.status !== 204) {
|
|
||||||
const body = await res.text().catch(() => '');
|
|
||||||
throw new Error(`Failed to delete agent (${res.status}): ${body}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Project endpoints ──
|
|
||||||
|
|
||||||
export async function fetchProjects(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
): Promise<ProjectInfo[]> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/projects`, {
|
|
||||||
headers: headers(sessionCookie, gatewayUrl),
|
|
||||||
});
|
|
||||||
return handleResponse<ProjectInfo[]>(res, 'Failed to list projects');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mission endpoints ──
|
|
||||||
|
|
||||||
export async function fetchMissions(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
): Promise<MissionInfo[]> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/missions`, {
|
|
||||||
headers: headers(sessionCookie, gatewayUrl),
|
|
||||||
});
|
|
||||||
return handleResponse<MissionInfo[]>(res, 'Failed to list missions');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchMission(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
id: string,
|
|
||||||
): Promise<MissionInfo> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
|
||||||
headers: headers(sessionCookie, gatewayUrl),
|
|
||||||
});
|
|
||||||
return handleResponse<MissionInfo>(res, 'Failed to get mission');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createMission(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
projectId?: string;
|
|
||||||
status?: string;
|
|
||||||
phase?: string;
|
|
||||||
milestones?: Record<string, unknown>[];
|
|
||||||
config?: Record<string, unknown>;
|
|
||||||
},
|
|
||||||
): Promise<MissionInfo> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/missions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
return handleResponse<MissionInfo>(res, 'Failed to create mission');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateMission(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
id: string,
|
|
||||||
data: Record<string, unknown>,
|
|
||||||
): Promise<MissionInfo> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
return handleResponse<MissionInfo>(res, 'Failed to update mission');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteMission(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
id: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(id)}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: headers(sessionCookie, gatewayUrl),
|
|
||||||
});
|
|
||||||
if (!res.ok && res.status !== 204) {
|
|
||||||
const body = await res.text().catch(() => '');
|
|
||||||
throw new Error(`Failed to delete mission (${res.status}): ${body}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Conversation Message types ──
|
|
||||||
|
|
||||||
export interface ConversationMessage {
|
|
||||||
id: string;
|
|
||||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
|
||||||
content: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Conversation Message endpoints ──
|
|
||||||
|
|
||||||
export async function fetchConversationMessages(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
conversationId: string,
|
|
||||||
): Promise<ConversationMessage[]> {
|
|
||||||
const res = await fetch(
|
|
||||||
`${gatewayUrl}/api/conversations/${encodeURIComponent(conversationId)}/messages`,
|
|
||||||
{
|
|
||||||
headers: headers(sessionCookie, gatewayUrl),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return handleResponse<ConversationMessage[]>(res, 'Failed to fetch conversation messages');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mission Task endpoints ──
|
|
||||||
|
|
||||||
export async function fetchMissionTasks(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
missionId: string,
|
|
||||||
): Promise<MissionTaskInfo[]> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
|
|
||||||
headers: headers(sessionCookie, gatewayUrl),
|
|
||||||
});
|
|
||||||
return handleResponse<MissionTaskInfo[]>(res, 'Failed to list mission tasks');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createMissionTask(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
missionId: string,
|
|
||||||
data: {
|
|
||||||
description?: string;
|
|
||||||
status?: string;
|
|
||||||
notes?: string;
|
|
||||||
pr?: string;
|
|
||||||
taskId?: string;
|
|
||||||
},
|
|
||||||
): Promise<MissionTaskInfo> {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
return handleResponse<MissionTaskInfo>(res, 'Failed to create mission task');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateMissionTask(
|
|
||||||
gatewayUrl: string,
|
|
||||||
sessionCookie: string,
|
|
||||||
missionId: string,
|
|
||||||
taskId: string,
|
|
||||||
data: Record<string, unknown>,
|
|
||||||
): Promise<MissionTaskInfo> {
|
|
||||||
const res = await fetch(
|
|
||||||
`${gatewayUrl}/api/missions/${encodeURIComponent(missionId)}/tasks/${encodeURIComponent(taskId)}`,
|
|
||||||
{
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: jsonHeaders(sessionCookie, gatewayUrl),
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return handleResponse<MissionTaskInfo>(res, 'Failed to update mission task');
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
|
|
||||||
export type AppMode = 'chat' | 'sidebar' | 'search';
|
|
||||||
|
|
||||||
export interface UseAppModeReturn {
|
|
||||||
mode: AppMode;
|
|
||||||
setMode: (mode: AppMode) => void;
|
|
||||||
toggleSidebar: () => void;
|
|
||||||
sidebarOpen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAppMode(): UseAppModeReturn {
|
|
||||||
const [mode, setModeState] = useState<AppMode>('chat');
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
||||||
|
|
||||||
const setMode = useCallback((next: AppMode) => {
|
|
||||||
setModeState(next);
|
|
||||||
if (next === 'sidebar') {
|
|
||||||
setSidebarOpen(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleSidebar = useCallback(() => {
|
|
||||||
setSidebarOpen((prev) => {
|
|
||||||
if (prev) {
|
|
||||||
// Closing sidebar — return to chat
|
|
||||||
setModeState('chat');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Opening sidebar — set mode to sidebar
|
|
||||||
setModeState('sidebar');
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { mode, setMode, toggleSidebar, sidebarOpen };
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
|
|
||||||
export interface ConversationSummary {
|
|
||||||
id: string;
|
|
||||||
title: string | null;
|
|
||||||
archived: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseConversationsOptions {
|
|
||||||
gatewayUrl: string;
|
|
||||||
sessionCookie?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseConversationsReturn {
|
|
||||||
conversations: ConversationSummary[];
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
createConversation: (title?: string) => Promise<ConversationSummary | null>;
|
|
||||||
deleteConversation: (id: string) => Promise<boolean>;
|
|
||||||
renameConversation: (id: string, title: string) => Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useConversations(opts: UseConversationsOptions): UseConversationsReturn {
|
|
||||||
const { gatewayUrl, sessionCookie } = opts;
|
|
||||||
|
|
||||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const mountedRef = useRef(true);
|
|
||||||
|
|
||||||
const headers = useCallback(
|
|
||||||
(includeContentType = true): Record<string, string> => {
|
|
||||||
const h: Record<string, string> = { Origin: gatewayUrl };
|
|
||||||
if (includeContentType) h['Content-Type'] = 'application/json';
|
|
||||||
if (sessionCookie) h['Cookie'] = sessionCookie;
|
|
||||||
return h;
|
|
||||||
},
|
|
||||||
[gatewayUrl, sessionCookie],
|
|
||||||
);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
if (!mountedRef.current) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/conversations`, { headers: headers(false) });
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const data = (await res.json()) as ConversationSummary[];
|
|
||||||
if (mountedRef.current) {
|
|
||||||
setConversations(data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (mountedRef.current) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mountedRef.current) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [gatewayUrl, headers]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
mountedRef.current = true;
|
|
||||||
void refresh();
|
|
||||||
return () => {
|
|
||||||
mountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const createConversation = useCallback(
|
|
||||||
async (title?: string): Promise<ConversationSummary | null> => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/conversations`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: headers(),
|
|
||||||
body: JSON.stringify({ title: title ?? null }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const data = (await res.json()) as ConversationSummary;
|
|
||||||
if (mountedRef.current) {
|
|
||||||
setConversations((prev) => [data, ...prev]);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[gatewayUrl, headers],
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteConversation = useCallback(
|
|
||||||
async (id: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: headers(false),
|
|
||||||
});
|
|
||||||
if (!res.ok) return false;
|
|
||||||
if (mountedRef.current) {
|
|
||||||
setConversations((prev) => prev.filter((c) => c.id !== id));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[gatewayUrl, headers],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renameConversation = useCallback(
|
|
||||||
async (id: string, title: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: headers(),
|
|
||||||
body: JSON.stringify({ title }),
|
|
||||||
});
|
|
||||||
if (!res.ok) return false;
|
|
||||||
if (mountedRef.current) {
|
|
||||||
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[gatewayUrl, headers],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
conversations,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
refresh,
|
|
||||||
createConversation,
|
|
||||||
deleteConversation,
|
|
||||||
renameConversation,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { execSync } from 'node:child_process';
|
|
||||||
|
|
||||||
export interface GitInfo {
|
|
||||||
branch: string | null;
|
|
||||||
cwd: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGitInfo(): GitInfo {
|
|
||||||
const [info, setInfo] = useState<GitInfo>({
|
|
||||||
branch: null,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
timeout: 3000,
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
}).trim();
|
|
||||||
setInfo({ branch, cwd: process.cwd() });
|
|
||||||
} catch {
|
|
||||||
setInfo({ branch: null, cwd: process.cwd() });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for input history logic extracted from useInputHistory.
|
|
||||||
* We test the pure state transitions directly rather than using
|
|
||||||
* React testing utilities to avoid react-dom version conflicts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const MAX_HISTORY = 50;
|
|
||||||
|
|
||||||
function createHistoryState() {
|
|
||||||
let history: string[] = [];
|
|
||||||
let historyIndex = -1;
|
|
||||||
let savedInput = '';
|
|
||||||
|
|
||||||
function addToHistory(input: string): void {
|
|
||||||
if (!input.trim()) return;
|
|
||||||
if (history[0] === input) return;
|
|
||||||
history = [input, ...history].slice(0, MAX_HISTORY);
|
|
||||||
historyIndex = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateUp(currentInput: string): string | null {
|
|
||||||
if (history.length === 0) return null;
|
|
||||||
if (historyIndex === -1) {
|
|
||||||
savedInput = currentInput;
|
|
||||||
}
|
|
||||||
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
|
|
||||||
historyIndex = nextIndex;
|
|
||||||
return history[nextIndex] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateDown(): string | null {
|
|
||||||
if (historyIndex <= 0) {
|
|
||||||
historyIndex = -1;
|
|
||||||
return savedInput;
|
|
||||||
}
|
|
||||||
const nextIndex = historyIndex - 1;
|
|
||||||
historyIndex = nextIndex;
|
|
||||||
return history[nextIndex] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetNavigation(): void {
|
|
||||||
historyIndex = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHistoryLength(): number {
|
|
||||||
return history.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { addToHistory, navigateUp, navigateDown, resetNavigation, getHistoryLength };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('useInputHistory (logic)', () => {
|
|
||||||
let h: ReturnType<typeof createHistoryState>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
h = createHistoryState();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adds to history on submit', () => {
|
|
||||||
h.addToHistory('hello');
|
|
||||||
h.addToHistory('world');
|
|
||||||
// navigateUp should return 'world' first (most recent)
|
|
||||||
const val = h.navigateUp('');
|
|
||||||
expect(val).toBe('world');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not add empty strings to history', () => {
|
|
||||||
h.addToHistory('');
|
|
||||||
h.addToHistory(' ');
|
|
||||||
const val = h.navigateUp('');
|
|
||||||
expect(val).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('navigateDown after up returns saved input', () => {
|
|
||||||
h.addToHistory('first');
|
|
||||||
const up = h.navigateUp('current');
|
|
||||||
expect(up).toBe('first');
|
|
||||||
const down = h.navigateDown();
|
|
||||||
expect(down).toBe('current');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not add duplicate consecutive entries', () => {
|
|
||||||
h.addToHistory('same');
|
|
||||||
h.addToHistory('same');
|
|
||||||
expect(h.getHistoryLength()).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caps history at MAX_HISTORY entries', () => {
|
|
||||||
for (let i = 0; i < 55; i++) {
|
|
||||||
h.addToHistory(`entry-${i}`);
|
|
||||||
}
|
|
||||||
expect(h.getHistoryLength()).toBe(50);
|
|
||||||
// Navigate to the oldest entry
|
|
||||||
let val: string | null = null;
|
|
||||||
for (let i = 0; i < 60; i++) {
|
|
||||||
val = h.navigateUp('');
|
|
||||||
}
|
|
||||||
// Oldest entry at index 49 = entry-5 (entries 54 down to 5, 50 total)
|
|
||||||
expect(val).toBe('entry-5');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('navigateUp returns null when history is empty', () => {
|
|
||||||
const val = h.navigateUp('something');
|
|
||||||
expect(val).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('navigateUp cycles through multiple entries', () => {
|
|
||||||
h.addToHistory('a');
|
|
||||||
h.addToHistory('b');
|
|
||||||
h.addToHistory('c');
|
|
||||||
expect(h.navigateUp('')).toBe('c');
|
|
||||||
expect(h.navigateUp('c')).toBe('b');
|
|
||||||
expect(h.navigateUp('b')).toBe('a');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resetNavigation resets index to -1', () => {
|
|
||||||
h.addToHistory('test');
|
|
||||||
h.navigateUp('');
|
|
||||||
h.resetNavigation();
|
|
||||||
// After reset, navigateUp from index -1 returns most recent again
|
|
||||||
const val = h.navigateUp('');
|
|
||||||
expect(val).toBe('test');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
|
|
||||||
const MAX_HISTORY = 50;
|
|
||||||
|
|
||||||
export function useInputHistory() {
|
|
||||||
const [history, setHistory] = useState<string[]>([]);
|
|
||||||
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
|
||||||
const [savedInput, setSavedInput] = useState<string>('');
|
|
||||||
|
|
||||||
const addToHistory = useCallback((input: string) => {
|
|
||||||
if (!input.trim()) return;
|
|
||||||
setHistory((prev) => {
|
|
||||||
// Avoid duplicate consecutive entries
|
|
||||||
if (prev[0] === input) return prev;
|
|
||||||
return [input, ...prev].slice(0, MAX_HISTORY);
|
|
||||||
});
|
|
||||||
setHistoryIndex(-1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navigateUp = useCallback(
|
|
||||||
(currentInput: string): string | null => {
|
|
||||||
if (history.length === 0) return null;
|
|
||||||
if (historyIndex === -1) {
|
|
||||||
setSavedInput(currentInput);
|
|
||||||
}
|
|
||||||
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
|
|
||||||
setHistoryIndex(nextIndex);
|
|
||||||
return history[nextIndex] ?? null;
|
|
||||||
},
|
|
||||||
[history, historyIndex],
|
|
||||||
);
|
|
||||||
|
|
||||||
const navigateDown = useCallback((): string | null => {
|
|
||||||
if (historyIndex <= 0) {
|
|
||||||
setHistoryIndex(-1);
|
|
||||||
return savedInput;
|
|
||||||
}
|
|
||||||
const nextIndex = historyIndex - 1;
|
|
||||||
setHistoryIndex(nextIndex);
|
|
||||||
return history[nextIndex] ?? null;
|
|
||||||
}, [history, historyIndex, savedInput]);
|
|
||||||
|
|
||||||
const resetNavigation = useCallback(() => {
|
|
||||||
setHistoryIndex(-1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { addToHistory, navigateUp, navigateDown, resetNavigation };
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
|
||||||
import type { Message } from './use-socket.js';
|
|
||||||
|
|
||||||
export interface SearchMatch {
|
|
||||||
messageIndex: number;
|
|
||||||
charOffset: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseSearchReturn {
|
|
||||||
query: string;
|
|
||||||
setQuery: (q: string) => void;
|
|
||||||
matches: SearchMatch[];
|
|
||||||
currentMatchIndex: number;
|
|
||||||
nextMatch: () => void;
|
|
||||||
prevMatch: () => void;
|
|
||||||
clear: () => void;
|
|
||||||
totalMatches: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSearch(messages: Message[]): UseSearchReturn {
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
|
||||||
|
|
||||||
const matches = useMemo<SearchMatch[]>(() => {
|
|
||||||
if (query.length < 2) return [];
|
|
||||||
|
|
||||||
const lowerQuery = query.toLowerCase();
|
|
||||||
const result: SearchMatch[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
|
||||||
const msg = messages[i];
|
|
||||||
if (!msg) continue;
|
|
||||||
const content = msg.content.toLowerCase();
|
|
||||||
let offset = 0;
|
|
||||||
while (true) {
|
|
||||||
const idx = content.indexOf(lowerQuery, offset);
|
|
||||||
if (idx === -1) break;
|
|
||||||
result.push({ messageIndex: i, charOffset: idx });
|
|
||||||
offset = idx + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [query, messages]);
|
|
||||||
|
|
||||||
// Reset match index when matches change
|
|
||||||
useMemo(() => {
|
|
||||||
setCurrentMatchIndex(0);
|
|
||||||
}, [matches]);
|
|
||||||
|
|
||||||
const nextMatch = useCallback(() => {
|
|
||||||
if (matches.length === 0) return;
|
|
||||||
setCurrentMatchIndex((prev) => (prev + 1) % matches.length);
|
|
||||||
}, [matches.length]);
|
|
||||||
|
|
||||||
const prevMatch = useCallback(() => {
|
|
||||||
if (matches.length === 0) return;
|
|
||||||
setCurrentMatchIndex((prev) => (prev - 1 + matches.length) % matches.length);
|
|
||||||
}, [matches.length]);
|
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
|
||||||
setQuery('');
|
|
||||||
setCurrentMatchIndex(0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
query,
|
|
||||||
setQuery,
|
|
||||||
matches,
|
|
||||||
currentMatchIndex,
|
|
||||||
nextMatch,
|
|
||||||
prevMatch,
|
|
||||||
clear,
|
|
||||||
totalMatches: matches.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
import { type MutableRefObject, useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { io, type Socket } from 'socket.io-client';
|
|
||||||
import type {
|
|
||||||
ServerToClientEvents,
|
|
||||||
ClientToServerEvents,
|
|
||||||
MessageAckPayload,
|
|
||||||
AgentEndPayload,
|
|
||||||
AgentTextPayload,
|
|
||||||
AgentThinkingPayload,
|
|
||||||
ToolStartPayload,
|
|
||||||
ToolEndPayload,
|
|
||||||
SessionInfoPayload,
|
|
||||||
ErrorPayload,
|
|
||||||
CommandManifestPayload,
|
|
||||||
SlashCommandResultPayload,
|
|
||||||
SystemReloadPayload,
|
|
||||||
RoutingDecisionInfo,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import { commandRegistry } from '../commands/index.js';
|
|
||||||
|
|
||||||
export interface ToolCall {
|
|
||||||
toolCallId: string;
|
|
||||||
toolName: string;
|
|
||||||
status: 'running' | 'success' | 'error';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Message {
|
|
||||||
role: 'user' | 'assistant' | 'thinking' | 'tool' | 'system';
|
|
||||||
content: string;
|
|
||||||
timestamp: Date;
|
|
||||||
toolCalls?: ToolCall[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenUsage {
|
|
||||||
input: number;
|
|
||||||
output: number;
|
|
||||||
total: number;
|
|
||||||
cacheRead: number;
|
|
||||||
cacheWrite: number;
|
|
||||||
cost: number;
|
|
||||||
contextPercent: number;
|
|
||||||
contextWindow: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseSocketOptions {
|
|
||||||
gatewayUrl: string;
|
|
||||||
sessionCookie?: string;
|
|
||||||
initialConversationId?: string;
|
|
||||||
initialModel?: string;
|
|
||||||
initialProvider?: string;
|
|
||||||
agentId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
|
||||||
|
|
||||||
export interface UseSocketReturn {
|
|
||||||
connected: boolean;
|
|
||||||
connecting: boolean;
|
|
||||||
messages: Message[];
|
|
||||||
conversationId: string | undefined;
|
|
||||||
isStreaming: boolean;
|
|
||||||
currentStreamText: string;
|
|
||||||
currentThinkingText: string;
|
|
||||||
activeToolCalls: ToolCall[];
|
|
||||||
tokenUsage: TokenUsage;
|
|
||||||
modelName: string | null;
|
|
||||||
providerName: string | null;
|
|
||||||
thinkingLevel: string;
|
|
||||||
availableThinkingLevels: string[];
|
|
||||||
/** Last routing decision received from the gateway (M4-008) */
|
|
||||||
routingDecision: RoutingDecisionInfo | null;
|
|
||||||
sendMessage: (content: string) => void;
|
|
||||||
addSystemMessage: (content: string) => void;
|
|
||||||
setThinkingLevel: (level: string) => void;
|
|
||||||
switchConversation: (id: string) => void;
|
|
||||||
clearMessages: () => void;
|
|
||||||
connectionError: string | null;
|
|
||||||
socketRef: MutableRefObject<TypedSocket | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_USAGE: TokenUsage = {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
total: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
cost: 0,
|
|
||||||
contextPercent: 0,
|
|
||||||
contextWindow: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
|
||||||
const {
|
|
||||||
gatewayUrl,
|
|
||||||
sessionCookie,
|
|
||||||
initialConversationId,
|
|
||||||
initialModel,
|
|
||||||
initialProvider,
|
|
||||||
agentId,
|
|
||||||
} = opts;
|
|
||||||
|
|
||||||
const [connected, setConnected] = useState(false);
|
|
||||||
const [connecting, setConnecting] = useState(true);
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [conversationId, setConversationId] = useState(initialConversationId);
|
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
|
||||||
const [currentStreamText, setCurrentStreamText] = useState('');
|
|
||||||
const [currentThinkingText, setCurrentThinkingText] = useState('');
|
|
||||||
const [activeToolCalls, setActiveToolCalls] = useState<ToolCall[]>([]);
|
|
||||||
const [tokenUsage, setTokenUsage] = useState<TokenUsage>(EMPTY_USAGE);
|
|
||||||
const [modelName, setModelName] = useState<string | null>(null);
|
|
||||||
const [providerName, setProviderName] = useState<string | null>(null);
|
|
||||||
const [thinkingLevel, setThinkingLevelState] = useState<string>('off');
|
|
||||||
const [availableThinkingLevels, setAvailableThinkingLevels] = useState<string[]>([]);
|
|
||||||
const [routingDecision, setRoutingDecision] = useState<RoutingDecisionInfo | null>(null);
|
|
||||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const socketRef = useRef<TypedSocket | null>(null);
|
|
||||||
const conversationIdRef = useRef(conversationId);
|
|
||||||
conversationIdRef.current = conversationId;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const socket = io(`${gatewayUrl}/chat`, {
|
|
||||||
transports: ['websocket'],
|
|
||||||
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
|
|
||||||
reconnection: true,
|
|
||||||
reconnectionDelay: 2000,
|
|
||||||
reconnectionAttempts: Infinity,
|
|
||||||
}) as TypedSocket;
|
|
||||||
|
|
||||||
socketRef.current = socket;
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
setConnected(true);
|
|
||||||
setConnecting(false);
|
|
||||||
setConnectionError(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
|
||||||
setConnected(false);
|
|
||||||
setIsStreaming(false);
|
|
||||||
setCurrentStreamText('');
|
|
||||||
setCurrentThinkingText('');
|
|
||||||
setActiveToolCalls([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.io.on('error', (err: Error) => {
|
|
||||||
setConnecting(false);
|
|
||||||
setConnectionError(err.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('message:ack', (data: MessageAckPayload) => {
|
|
||||||
setConversationId(data.conversationId);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('session:info', (data: SessionInfoPayload) => {
|
|
||||||
setProviderName(data.provider);
|
|
||||||
setModelName(data.modelId);
|
|
||||||
setThinkingLevelState(data.thinkingLevel);
|
|
||||||
setAvailableThinkingLevels(data.availableThinkingLevels);
|
|
||||||
// Update routing decision if provided (M4-008)
|
|
||||||
if (data.routingDecision) {
|
|
||||||
setRoutingDecision(data.routingDecision);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('agent:start', () => {
|
|
||||||
setIsStreaming(true);
|
|
||||||
setCurrentStreamText('');
|
|
||||||
setCurrentThinkingText('');
|
|
||||||
setActiveToolCalls([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('agent:text', (data: AgentTextPayload) => {
|
|
||||||
setCurrentStreamText((prev) => prev + data.text);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('agent:thinking', (data: AgentThinkingPayload) => {
|
|
||||||
setCurrentThinkingText((prev) => prev + data.text);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('agent:tool:start', (data: ToolStartPayload) => {
|
|
||||||
setActiveToolCalls((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ toolCallId: data.toolCallId, toolName: data.toolName, status: 'running' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('agent:tool:end', (data: ToolEndPayload) => {
|
|
||||||
setActiveToolCalls((prev) =>
|
|
||||||
prev.map((tc) =>
|
|
||||||
tc.toolCallId === data.toolCallId
|
|
||||||
? { ...tc, status: data.isError ? 'error' : 'success' }
|
|
||||||
: tc,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('agent:end', (data: AgentEndPayload) => {
|
|
||||||
setCurrentStreamText((prev) => {
|
|
||||||
if (prev) {
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{ role: 'assistant', content: prev, timestamp: new Date() },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
setCurrentThinkingText('');
|
|
||||||
setActiveToolCalls([]);
|
|
||||||
setIsStreaming(false);
|
|
||||||
|
|
||||||
// Update usage from the payload
|
|
||||||
if (data.usage) {
|
|
||||||
setProviderName(data.usage.provider);
|
|
||||||
setModelName(data.usage.modelId);
|
|
||||||
setThinkingLevelState(data.usage.thinkingLevel);
|
|
||||||
setTokenUsage({
|
|
||||||
input: data.usage.tokens.input,
|
|
||||||
output: data.usage.tokens.output,
|
|
||||||
total: data.usage.tokens.total,
|
|
||||||
cacheRead: data.usage.tokens.cacheRead,
|
|
||||||
cacheWrite: data.usage.tokens.cacheWrite,
|
|
||||||
cost: data.usage.cost,
|
|
||||||
contextPercent: data.usage.context.percent ?? 0,
|
|
||||||
contextWindow: data.usage.context.window,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (data: ErrorPayload) => {
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{ role: 'assistant', content: `Error: ${data.error}`, timestamp: new Date() },
|
|
||||||
]);
|
|
||||||
setIsStreaming(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('commands:manifest', (data: CommandManifestPayload) => {
|
|
||||||
commandRegistry.updateManifest(data.manifest);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('command:result', (data: SlashCommandResultPayload) => {
|
|
||||||
const prefix = data.success ? '' : 'Error: ';
|
|
||||||
const text = data.message ?? (data.success ? 'Done.' : 'Command failed.');
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{ role: 'system', content: `${prefix}${text}`, timestamp: new Date() },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('system:reload', (data: SystemReloadPayload) => {
|
|
||||||
commandRegistry.updateManifest({
|
|
||||||
commands: data.commands,
|
|
||||||
skills: data.skills,
|
|
||||||
version: Date.now(),
|
|
||||||
});
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{ role: 'system', content: data.message, timestamp: new Date() },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
socket.disconnect();
|
|
||||||
};
|
|
||||||
}, [gatewayUrl, sessionCookie]);
|
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
|
||||||
(content: string) => {
|
|
||||||
if (!content.trim() || isStreaming) return;
|
|
||||||
if (!socketRef.current?.connected) return;
|
|
||||||
|
|
||||||
setMessages((msgs) => [...msgs, { role: 'user', content, timestamp: new Date() }]);
|
|
||||||
|
|
||||||
socketRef.current.emit('message', {
|
|
||||||
conversationId,
|
|
||||||
content,
|
|
||||||
...(initialProvider ? { provider: initialProvider } : {}),
|
|
||||||
...(initialModel ? { modelId: initialModel } : {}),
|
|
||||||
...(agentId ? { agentId } : {}),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[conversationId, isStreaming],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addSystemMessage = useCallback((content: string) => {
|
|
||||||
setMessages((msgs) => [...msgs, { role: 'system', content, timestamp: new Date() }]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setThinkingLevel = useCallback((level: string) => {
|
|
||||||
const cid = conversationIdRef.current;
|
|
||||||
if (!socketRef.current?.connected || !cid) return;
|
|
||||||
socketRef.current.emit('set:thinking', {
|
|
||||||
conversationId: cid,
|
|
||||||
level,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearMessages = useCallback(() => {
|
|
||||||
setMessages([]);
|
|
||||||
setCurrentStreamText('');
|
|
||||||
setCurrentThinkingText('');
|
|
||||||
setActiveToolCalls([]);
|
|
||||||
setIsStreaming(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const switchConversation = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
clearMessages();
|
|
||||||
setConversationId(id);
|
|
||||||
},
|
|
||||||
[clearMessages],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
connected,
|
|
||||||
connecting,
|
|
||||||
messages,
|
|
||||||
conversationId,
|
|
||||||
isStreaming,
|
|
||||||
currentStreamText,
|
|
||||||
currentThinkingText,
|
|
||||||
activeToolCalls,
|
|
||||||
tokenUsage,
|
|
||||||
modelName,
|
|
||||||
providerName,
|
|
||||||
thinkingLevel,
|
|
||||||
availableThinkingLevels,
|
|
||||||
routingDecision,
|
|
||||||
sendMessage,
|
|
||||||
addSystemMessage,
|
|
||||||
setThinkingLevel,
|
|
||||||
switchConversation,
|
|
||||||
clearMessages,
|
|
||||||
connectionError,
|
|
||||||
socketRef,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
|
||||||
import { useStdout } from 'ink';
|
|
||||||
|
|
||||||
export interface UseViewportOptions {
|
|
||||||
totalItems: number;
|
|
||||||
reservedLines?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseViewportReturn {
|
|
||||||
scrollOffset: number;
|
|
||||||
viewportSize: number;
|
|
||||||
isScrolledUp: boolean;
|
|
||||||
scrollToBottom: () => void;
|
|
||||||
scrollBy: (delta: number) => void;
|
|
||||||
scrollTo: (offset: number) => void;
|
|
||||||
canScrollUp: boolean;
|
|
||||||
canScrollDown: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useViewport({
|
|
||||||
totalItems,
|
|
||||||
reservedLines = 10,
|
|
||||||
}: UseViewportOptions): UseViewportReturn {
|
|
||||||
const { stdout } = useStdout();
|
|
||||||
const rows = stdout?.rows ?? 24;
|
|
||||||
const viewportSize = Math.max(1, rows - reservedLines);
|
|
||||||
|
|
||||||
const [scrollOffset, setScrollOffset] = useState(0);
|
|
||||||
const [autoFollow, setAutoFollow] = useState(true);
|
|
||||||
|
|
||||||
// Compute the maximum valid scroll offset
|
|
||||||
const maxOffset = Math.max(0, totalItems - viewportSize);
|
|
||||||
|
|
||||||
// Auto-follow: when new items arrive and auto-follow is on, snap to bottom
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoFollow) {
|
|
||||||
setScrollOffset(maxOffset);
|
|
||||||
}
|
|
||||||
}, [autoFollow, maxOffset]);
|
|
||||||
|
|
||||||
const scrollTo = useCallback(
|
|
||||||
(offset: number) => {
|
|
||||||
const clamped = Math.max(0, Math.min(offset, maxOffset));
|
|
||||||
setScrollOffset(clamped);
|
|
||||||
setAutoFollow(clamped >= maxOffset);
|
|
||||||
},
|
|
||||||
[maxOffset],
|
|
||||||
);
|
|
||||||
|
|
||||||
const scrollBy = useCallback(
|
|
||||||
(delta: number) => {
|
|
||||||
setScrollOffset((prev) => {
|
|
||||||
const next = Math.max(0, Math.min(prev + delta, maxOffset));
|
|
||||||
setAutoFollow(next >= maxOffset);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[maxOffset],
|
|
||||||
);
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
|
||||||
setScrollOffset(maxOffset);
|
|
||||||
setAutoFollow(true);
|
|
||||||
}, [maxOffset]);
|
|
||||||
|
|
||||||
const isScrolledUp = scrollOffset < maxOffset;
|
|
||||||
const canScrollUp = scrollOffset > 0;
|
|
||||||
const canScrollDown = scrollOffset < maxOffset;
|
|
||||||
|
|
||||||
return {
|
|
||||||
scrollOffset,
|
|
||||||
viewportSize,
|
|
||||||
isScrolledUp,
|
|
||||||
scrollToBottom,
|
|
||||||
scrollBy,
|
|
||||||
scrollTo,
|
|
||||||
canScrollUp,
|
|
||||||
canScrollDown,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.spec.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"jsx": "react-jsx"
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
globals: true,
|
|
||||||
environment: 'node',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/config"
|
"directory": "packages/config"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/coord"
|
"directory": "packages/coord"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/db"
|
"directory": "packages/db"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/design-tokens"
|
"directory": "packages/design-tokens"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/forge",
|
"name": "@mosaicstack/forge",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/forge"
|
"directory": "packages/forge"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -26,7 +26,8 @@
|
|||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/macp": "workspace:*"
|
"@mosaicstack/macp": "workspace:*",
|
||||||
|
"commander": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
57
packages/forge/src/cli.spec.ts
Normal file
57
packages/forge/src/cli.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { registerForgeCommand } from './cli.js';
|
||||||
|
|
||||||
|
describe('registerForgeCommand', () => {
|
||||||
|
it('registers a "forge" command on the parent program', () => {
|
||||||
|
const program = new Command();
|
||||||
|
registerForgeCommand(program);
|
||||||
|
|
||||||
|
const forgeCmd = program.commands.find((c) => c.name() === 'forge');
|
||||||
|
expect(forgeCmd).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers the four required subcommands under forge', () => {
|
||||||
|
const program = new Command();
|
||||||
|
registerForgeCommand(program);
|
||||||
|
|
||||||
|
const forgeCmd = program.commands.find((c) => c.name() === 'forge');
|
||||||
|
expect(forgeCmd).toBeDefined();
|
||||||
|
|
||||||
|
const subNames = forgeCmd!.commands.map((c) => c.name());
|
||||||
|
|
||||||
|
expect(subNames).toContain('run');
|
||||||
|
expect(subNames).toContain('status');
|
||||||
|
expect(subNames).toContain('resume');
|
||||||
|
expect(subNames).toContain('personas');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers "personas list" as a subcommand of "forge personas"', () => {
|
||||||
|
const program = new Command();
|
||||||
|
registerForgeCommand(program);
|
||||||
|
|
||||||
|
const forgeCmd = program.commands.find((c) => c.name() === 'forge');
|
||||||
|
const personasCmd = forgeCmd!.commands.find((c) => c.name() === 'personas');
|
||||||
|
expect(personasCmd).toBeDefined();
|
||||||
|
|
||||||
|
const personasSubNames = personasCmd!.commands.map((c) => c.name());
|
||||||
|
expect(personasSubNames).toContain('list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not modify the parent program name or description', () => {
|
||||||
|
const program = new Command('mosaic');
|
||||||
|
program.description('Mosaic Stack CLI');
|
||||||
|
registerForgeCommand(program);
|
||||||
|
|
||||||
|
expect(program.name()).toBe('mosaic');
|
||||||
|
expect(program.description()).toBe('Mosaic Stack CLI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be called multiple times without throwing', () => {
|
||||||
|
const program = new Command();
|
||||||
|
expect(() => {
|
||||||
|
registerForgeCommand(program);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
280
packages/forge/src/cli.ts
Normal file
280
packages/forge/src/cli.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type { Command } from 'commander';
|
||||||
|
|
||||||
|
import { classifyBrief } from './brief-classifier.js';
|
||||||
|
import { STAGE_LABELS, STAGE_SEQUENCE } from './constants.js';
|
||||||
|
import { getEffectivePersonas, loadBoardPersonas } from './persona-loader.js';
|
||||||
|
import { generateRunId, getPipelineStatus, loadManifest, runPipeline } from './pipeline-runner.js';
|
||||||
|
import type { PipelineOptions, RunManifest, TaskExecutor } from './types.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stub executor — used when no real executor is wired at CLI invocation time.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const stubExecutor: TaskExecutor = {
|
||||||
|
async submitTask(task) {
|
||||||
|
console.log(` [forge] stage submitted: ${task.id} (${task.title})`);
|
||||||
|
},
|
||||||
|
async waitForCompletion(taskId, _timeoutMs) {
|
||||||
|
console.log(` [forge] stage complete: ${taskId}`);
|
||||||
|
return {
|
||||||
|
task_id: taskId,
|
||||||
|
status: 'completed' as const,
|
||||||
|
completed_at: new Date().toISOString(),
|
||||||
|
exit_code: 0,
|
||||||
|
gate_results: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async getTaskStatus(_taskId) {
|
||||||
|
return 'completed' as const;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatDuration(startedAt?: string, completedAt?: string): string {
|
||||||
|
if (!startedAt || !completedAt) return '-';
|
||||||
|
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
||||||
|
const secs = Math.round(ms / 1000);
|
||||||
|
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m${secs % 60}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printManifestTable(manifest: RunManifest): void {
|
||||||
|
console.log(`\nRun ID : ${manifest.runId}`);
|
||||||
|
console.log(`Status : ${manifest.status}`);
|
||||||
|
console.log(`Brief : ${manifest.brief}`);
|
||||||
|
console.log(`Class : ${manifest.briefClass} (${manifest.classSource})`);
|
||||||
|
console.log(`Updated: ${manifest.updatedAt}`);
|
||||||
|
console.log('');
|
||||||
|
console.log('Stage'.padEnd(22) + 'Status'.padEnd(14) + 'Duration');
|
||||||
|
console.log('-'.repeat(50));
|
||||||
|
for (const stage of STAGE_SEQUENCE) {
|
||||||
|
const s = manifest.stages[stage];
|
||||||
|
if (!s) continue;
|
||||||
|
const label = (STAGE_LABELS[stage] ?? stage).padEnd(22);
|
||||||
|
const status = s.status.padEnd(14);
|
||||||
|
const dur = formatDuration(s.startedAt, s.completedAt);
|
||||||
|
console.log(`${label}${status}${dur}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRunDir(runId: string, projectRoot?: string): string {
|
||||||
|
const root = projectRoot ?? process.cwd();
|
||||||
|
return path.join(root, '.forge', 'runs', runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listRecentRuns(projectRoot?: string): void {
|
||||||
|
const root = projectRoot ?? process.cwd();
|
||||||
|
const runsDir = path.join(root, '.forge', 'runs');
|
||||||
|
|
||||||
|
if (!fs.existsSync(runsDir)) {
|
||||||
|
console.log('No runs found. Run `mosaic forge run` to start a pipeline.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fs
|
||||||
|
.readdirSync(runsDir)
|
||||||
|
.filter((name) => fs.statSync(path.join(runsDir, name)).isDirectory())
|
||||||
|
.sort()
|
||||||
|
.reverse()
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
console.log('No runs found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nRecent runs:');
|
||||||
|
console.log('Run ID'.padEnd(22) + 'Status'.padEnd(14) + 'Brief');
|
||||||
|
console.log('-'.repeat(70));
|
||||||
|
|
||||||
|
for (const runId of entries) {
|
||||||
|
const runDir = path.join(runsDir, runId);
|
||||||
|
try {
|
||||||
|
const manifest = loadManifest(runDir);
|
||||||
|
const status = manifest.status.padEnd(14);
|
||||||
|
const brief = path.basename(manifest.brief);
|
||||||
|
console.log(`${runId.padEnd(22)}${status}${brief}`);
|
||||||
|
} catch {
|
||||||
|
console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(14)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Register function
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register forge subcommands on an existing Commander program.
|
||||||
|
* Mirrors the pattern used by registerQualityRails in @mosaicstack/quality-rails.
|
||||||
|
*/
|
||||||
|
export function registerForgeCommand(parent: Command): void {
|
||||||
|
const forge = parent.command('forge').description('Run and manage Forge pipelines');
|
||||||
|
|
||||||
|
// ── forge run ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
forge
|
||||||
|
.command('run')
|
||||||
|
.description('Run a Forge pipeline from a brief markdown file')
|
||||||
|
.requiredOption('--brief <path>', 'Path to the brief markdown file')
|
||||||
|
.option('--run-id <id>', 'Override the auto-generated run ID')
|
||||||
|
.option('--resume', 'Resume an existing run instead of starting a new one', false)
|
||||||
|
.option('--config <path>', 'Path to forge config file (.forge/config.yaml)')
|
||||||
|
.option('--codebase <path>', 'Codebase root to pass to the pipeline', process.cwd())
|
||||||
|
.option('--dry-run', 'Print planned stages without executing', false)
|
||||||
|
.action(
|
||||||
|
async (opts: {
|
||||||
|
brief: string;
|
||||||
|
runId?: string;
|
||||||
|
resume: boolean;
|
||||||
|
config?: string;
|
||||||
|
codebase: string;
|
||||||
|
dryRun: boolean;
|
||||||
|
}) => {
|
||||||
|
const briefPath = path.resolve(opts.brief);
|
||||||
|
|
||||||
|
if (!fs.existsSync(briefPath)) {
|
||||||
|
console.error(`[forge] brief not found: ${briefPath}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const briefContent = fs.readFileSync(briefPath, 'utf-8');
|
||||||
|
const briefClass = classifyBrief(briefContent);
|
||||||
|
const projectRoot = opts.codebase;
|
||||||
|
|
||||||
|
if (opts.resume) {
|
||||||
|
const runId = opts.runId ?? generateRunId();
|
||||||
|
const runDir = resolveRunDir(runId, projectRoot);
|
||||||
|
console.log(`[forge] resuming run: ${runId}`);
|
||||||
|
const { resumePipeline } = await import('./pipeline-runner.js');
|
||||||
|
const result = await resumePipeline(runDir, stubExecutor);
|
||||||
|
console.log(`[forge] pipeline complete: ${result.runId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipelineOptions: PipelineOptions = {
|
||||||
|
briefClass,
|
||||||
|
codebase: projectRoot,
|
||||||
|
dryRun: opts.dryRun,
|
||||||
|
executor: stubExecutor,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opts.dryRun) {
|
||||||
|
const { stagesForClass } = await import('./brief-classifier.js');
|
||||||
|
const stages = stagesForClass(briefClass);
|
||||||
|
console.log(`[forge] dry-run — brief class: ${briefClass}`);
|
||||||
|
console.log('[forge] planned stages:');
|
||||||
|
for (const stage of stages) {
|
||||||
|
console.log(` - ${stage} (${STAGE_LABELS[stage] ?? stage})`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[forge] starting pipeline for brief: ${briefPath}`);
|
||||||
|
console.log(`[forge] classified as: ${briefClass}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runPipeline(briefPath, projectRoot, pipelineOptions);
|
||||||
|
console.log(`[forge] pipeline complete: ${result.runId}`);
|
||||||
|
console.log(`[forge] run directory: ${result.runDir}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── forge status ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
forge
|
||||||
|
.command('status [runId]')
|
||||||
|
.description('Show the status of a pipeline run (omit runId to list recent runs)')
|
||||||
|
.option('--project <path>', 'Project root (defaults to cwd)', process.cwd())
|
||||||
|
.action(async (runId: string | undefined, opts: { project: string }) => {
|
||||||
|
if (!runId) {
|
||||||
|
listRecentRuns(opts.project);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runDir = resolveRunDir(runId, opts.project);
|
||||||
|
try {
|
||||||
|
const manifest = getPipelineStatus(runDir);
|
||||||
|
printManifestTable(manifest);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`[forge] could not load run "${runId}": ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── forge resume ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
forge
|
||||||
|
.command('resume <runId>')
|
||||||
|
.description('Resume a stopped or failed pipeline run')
|
||||||
|
.option('--project <path>', 'Project root (defaults to cwd)', process.cwd())
|
||||||
|
.action(async (runId: string, opts: { project: string }) => {
|
||||||
|
const runDir = resolveRunDir(runId, opts.project);
|
||||||
|
|
||||||
|
if (!fs.existsSync(runDir)) {
|
||||||
|
console.error(`[forge] run not found: ${runDir}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[forge] resuming run: ${runId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { resumePipeline } = await import('./pipeline-runner.js');
|
||||||
|
const result = await resumePipeline(runDir, stubExecutor);
|
||||||
|
console.log(`[forge] pipeline complete: ${result.runId}`);
|
||||||
|
console.log(`[forge] run directory: ${result.runDir}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[forge] resume failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── forge personas ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const personas = forge.command('personas').description('Manage Forge board personas');
|
||||||
|
|
||||||
|
personas
|
||||||
|
.command('list')
|
||||||
|
.description('List configured board personas')
|
||||||
|
.option(
|
||||||
|
'--project <path>',
|
||||||
|
'Project root for persona overrides (defaults to cwd)',
|
||||||
|
process.cwd(),
|
||||||
|
)
|
||||||
|
.option('--board-dir <path>', 'Override the board agents directory')
|
||||||
|
.action((opts: { project: string; boardDir?: string }) => {
|
||||||
|
const effectivePersonas = opts.boardDir
|
||||||
|
? loadBoardPersonas(opts.boardDir)
|
||||||
|
: getEffectivePersonas(opts.project);
|
||||||
|
|
||||||
|
if (effectivePersonas.length === 0) {
|
||||||
|
console.log('[forge] no board personas configured.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nBoard personas (${effectivePersonas.length}):\n`);
|
||||||
|
console.log('Slug'.padEnd(24) + 'Name');
|
||||||
|
console.log('-'.repeat(50));
|
||||||
|
for (const p of effectivePersonas) {
|
||||||
|
console.log(`${p.slug.padEnd(24)}${p.name}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -80,3 +80,6 @@ export {
|
|||||||
resumePipeline,
|
resumePipeline,
|
||||||
getPipelineStatus,
|
getPipelineStatus,
|
||||||
} from './pipeline-runner.js';
|
} from './pipeline-runner.js';
|
||||||
|
|
||||||
|
// CLI
|
||||||
|
export { registerForgeCommand } from './cli.js';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/log",
|
"name": "@mosaicstack/log",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/log"
|
"directory": "packages/log"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/db": "workspace:*",
|
"@mosaicstack/db": "workspace:*",
|
||||||
|
"commander": "^13.0.0",
|
||||||
"drizzle-orm": "^0.45.1"
|
"drizzle-orm": "^0.45.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
68
packages/log/src/cli.spec.ts
Normal file
68
packages/log/src/cli.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { registerLogCommand } from './cli.js';
|
||||||
|
|
||||||
|
function buildTestProgram(): Command {
|
||||||
|
const program = new Command('mosaic');
|
||||||
|
program.exitOverride(); // prevent process.exit in tests
|
||||||
|
registerLogCommand(program);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('registerLogCommand', () => {
|
||||||
|
it('registers a "log" subcommand on the parent', () => {
|
||||||
|
const program = buildTestProgram();
|
||||||
|
const names = program.commands.map((c) => c.name());
|
||||||
|
expect(names).toContain('log');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('log command has tail, search, export, and level subcommands', () => {
|
||||||
|
const program = buildTestProgram();
|
||||||
|
const logCmd = program.commands.find((c) => c.name() === 'log');
|
||||||
|
expect(logCmd).toBeDefined();
|
||||||
|
const subNames = logCmd!.commands.map((c) => c.name());
|
||||||
|
expect(subNames).toContain('tail');
|
||||||
|
expect(subNames).toContain('search');
|
||||||
|
expect(subNames).toContain('export');
|
||||||
|
expect(subNames).toContain('level');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tail subcommand has expected options', () => {
|
||||||
|
const program = buildTestProgram();
|
||||||
|
const logCmd = program.commands.find((c) => c.name() === 'log')!;
|
||||||
|
const tailCmd = logCmd.commands.find((c) => c.name() === 'tail')!;
|
||||||
|
const optionNames = tailCmd.options.map((o) => o.long);
|
||||||
|
expect(optionNames).toContain('--agent');
|
||||||
|
expect(optionNames).toContain('--level');
|
||||||
|
expect(optionNames).toContain('--category');
|
||||||
|
expect(optionNames).toContain('--tier');
|
||||||
|
expect(optionNames).toContain('--limit');
|
||||||
|
expect(optionNames).toContain('--db');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('search subcommand accepts a positional query argument', () => {
|
||||||
|
const program = buildTestProgram();
|
||||||
|
const logCmd = program.commands.find((c) => c.name() === 'log')!;
|
||||||
|
const searchCmd = logCmd.commands.find((c) => c.name() === 'search')!;
|
||||||
|
// Commander stores positional args in _args
|
||||||
|
const argNames = searchCmd.registeredArguments.map((a) => a.name());
|
||||||
|
expect(argNames).toContain('query');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('export subcommand accepts a positional path argument', () => {
|
||||||
|
const program = buildTestProgram();
|
||||||
|
const logCmd = program.commands.find((c) => c.name() === 'log')!;
|
||||||
|
const exportCmd = logCmd.commands.find((c) => c.name() === 'export')!;
|
||||||
|
const argNames = exportCmd.registeredArguments.map((a) => a.name());
|
||||||
|
expect(argNames).toContain('path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('level subcommand accepts a positional level argument', () => {
|
||||||
|
const program = buildTestProgram();
|
||||||
|
const logCmd = program.commands.find((c) => c.name() === 'log')!;
|
||||||
|
const levelCmd = logCmd.commands.find((c) => c.name() === 'level')!;
|
||||||
|
const argNames = levelCmd.registeredArguments.map((a) => a.name());
|
||||||
|
expect(argNames).toContain('level');
|
||||||
|
});
|
||||||
|
});
|
||||||
177
packages/log/src/cli.ts
Normal file
177
packages/log/src/cli.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
import type { Command } from 'commander';
|
||||||
|
|
||||||
|
import type { LogCategory, LogLevel, LogTier } from './agent-logs.js';
|
||||||
|
|
||||||
|
interface FilterOptions {
|
||||||
|
agent?: string;
|
||||||
|
level?: string;
|
||||||
|
category?: string;
|
||||||
|
tier?: string;
|
||||||
|
limit?: string;
|
||||||
|
db?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLimit(raw: string | undefined, defaultVal = 50): number {
|
||||||
|
if (!raw) return defaultVal;
|
||||||
|
const n = parseInt(raw, 10);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : defaultVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuery(opts: FilterOptions) {
|
||||||
|
return {
|
||||||
|
...(opts.agent ? { sessionId: opts.agent } : {}),
|
||||||
|
...(opts.level ? { level: opts.level as LogLevel } : {}),
|
||||||
|
...(opts.category ? { category: opts.category as LogCategory } : {}),
|
||||||
|
...(opts.tier ? { tier: opts.tier as LogTier } : {}),
|
||||||
|
limit: parseLimit(opts.limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDb(connectionString: string) {
|
||||||
|
const { createDb } = await import('@mosaicstack/db');
|
||||||
|
return createDb(connectionString);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConnectionString(opts: FilterOptions): string | undefined {
|
||||||
|
return opts.db ?? process.env['DATABASE_URL'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register log subcommands on an existing Commander program.
|
||||||
|
* This avoids cross-package Commander version mismatches by using the
|
||||||
|
* caller's Command instance directly.
|
||||||
|
*/
|
||||||
|
export function registerLogCommand(parent: Command): void {
|
||||||
|
const log = parent.command('log').description('Query and manage agent logs');
|
||||||
|
|
||||||
|
// ─── tail ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
log
|
||||||
|
.command('tail')
|
||||||
|
.description('Tail recent agent logs')
|
||||||
|
.option('--agent <id>', 'Filter by agent/session ID')
|
||||||
|
.option('--level <level>', 'Filter by log level (debug|info|warn|error)')
|
||||||
|
.option('--category <cat>', 'Filter by category (decision|tool_use|learning|error|general)')
|
||||||
|
.option('--tier <tier>', 'Filter by tier (hot|warm|cold)')
|
||||||
|
.option('--limit <n>', 'Number of logs to return (default 50)', '50')
|
||||||
|
.option('--db <connection-string>', 'Database connection string (or set DATABASE_URL)')
|
||||||
|
.action(async (opts: FilterOptions) => {
|
||||||
|
const connStr = resolveConnectionString(opts);
|
||||||
|
if (!connStr) {
|
||||||
|
console.error('Database connection required: use --db or set DATABASE_URL');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = await openDb(connStr);
|
||||||
|
try {
|
||||||
|
const { createLogService } = await import('./log-service.js');
|
||||||
|
const svc = createLogService(handle.db);
|
||||||
|
const query = buildQuery(opts);
|
||||||
|
|
||||||
|
const logs = await svc.logs.query(query);
|
||||||
|
if (logs.length === 0) {
|
||||||
|
console.log('No logs found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of logs) {
|
||||||
|
const ts = new Date(entry.createdAt).toISOString();
|
||||||
|
console.log(`[${ts}] [${entry.level}] [${entry.category}] ${entry.content}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── search ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
log
|
||||||
|
.command('search <query>')
|
||||||
|
.description('Full-text search over agent logs')
|
||||||
|
.option('--agent <id>', 'Filter by agent/session ID')
|
||||||
|
.option('--level <level>', 'Filter by log level (debug|info|warn|error)')
|
||||||
|
.option('--category <cat>', 'Filter by category (decision|tool_use|learning|error|general)')
|
||||||
|
.option('--tier <tier>', 'Filter by tier (hot|warm|cold)')
|
||||||
|
.option('--limit <n>', 'Number of logs to return (default 50)', '50')
|
||||||
|
.option('--db <connection-string>', 'Database connection string (or set DATABASE_URL)')
|
||||||
|
.action(async (query: string, opts: FilterOptions) => {
|
||||||
|
const connStr = resolveConnectionString(opts);
|
||||||
|
if (!connStr) {
|
||||||
|
console.error('Database connection required: use --db or set DATABASE_URL');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = await openDb(connStr);
|
||||||
|
try {
|
||||||
|
const { createLogService } = await import('./log-service.js');
|
||||||
|
const svc = createLogService(handle.db);
|
||||||
|
const baseQuery = buildQuery(opts);
|
||||||
|
|
||||||
|
const logs = await svc.logs.query(baseQuery);
|
||||||
|
const lowerQ = query.toLowerCase();
|
||||||
|
const matched = logs.filter(
|
||||||
|
(e) =>
|
||||||
|
e.content.toLowerCase().includes(lowerQ) ||
|
||||||
|
(e.metadata != null && JSON.stringify(e.metadata).toLowerCase().includes(lowerQ)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matched.length === 0) {
|
||||||
|
console.log('No matching logs found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const entry of matched) {
|
||||||
|
const ts = new Date(entry.createdAt).toISOString();
|
||||||
|
console.log(`[${ts}] [${entry.level}] [${entry.category}] ${entry.content}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── export ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
log
|
||||||
|
.command('export <path>')
|
||||||
|
.description('Export matching logs to an NDJSON file')
|
||||||
|
.option('--agent <id>', 'Filter by agent/session ID')
|
||||||
|
.option('--level <level>', 'Filter by log level (debug|info|warn|error)')
|
||||||
|
.option('--category <cat>', 'Filter by category (decision|tool_use|learning|error|general)')
|
||||||
|
.option('--tier <tier>', 'Filter by tier (hot|warm|cold)')
|
||||||
|
.option('--limit <n>', 'Number of logs to export (default 50)', '50')
|
||||||
|
.option('--db <connection-string>', 'Database connection string (or set DATABASE_URL)')
|
||||||
|
.action(async (outputPath: string, opts: FilterOptions) => {
|
||||||
|
const connStr = resolveConnectionString(opts);
|
||||||
|
if (!connStr) {
|
||||||
|
console.error('Database connection required: use --db or set DATABASE_URL');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = await openDb(connStr);
|
||||||
|
try {
|
||||||
|
const { createLogService } = await import('./log-service.js');
|
||||||
|
const svc = createLogService(handle.db);
|
||||||
|
const query = buildQuery(opts);
|
||||||
|
|
||||||
|
const logs = await svc.logs.query(query);
|
||||||
|
const ndjson = logs.map((e) => JSON.stringify(e)).join('\n');
|
||||||
|
writeFileSync(outputPath, ndjson, 'utf8');
|
||||||
|
console.log(`Exported ${logs.length} log(s) to ${outputPath}`);
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── level ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
log
|
||||||
|
.command('level <level>')
|
||||||
|
.description('Set runtime log level for the connected log service')
|
||||||
|
.action((level: string) => {
|
||||||
|
void level;
|
||||||
|
console.log(
|
||||||
|
'Runtime log level adjustment is not supported in current mode (DB-backed log service).',
|
||||||
|
);
|
||||||
|
process.exitCode = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,3 +9,4 @@ export {
|
|||||||
type LogTier,
|
type LogTier,
|
||||||
type LogQuery,
|
type LogQuery,
|
||||||
} from './agent-logs.js';
|
} from './agent-logs.js';
|
||||||
|
export { registerLogCommand } from './cli.js';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/macp",
|
"name": "@mosaicstack/macp",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/macp"
|
"directory": "packages/macp"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"default": "./src/index.ts"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -21,6 +21,9 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^13.0.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@vitest/coverage-v8": "^2.0.0",
|
"@vitest/coverage-v8": "^2.0.0",
|
||||||
|
|||||||
77
packages/macp/src/cli.spec.ts
Normal file
77
packages/macp/src/cli.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { registerMacpCommand } from './cli.js';
|
||||||
|
|
||||||
|
describe('registerMacpCommand', () => {
|
||||||
|
function buildProgram(): Command {
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride(); // prevent process.exit in tests
|
||||||
|
registerMacpCommand(program);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('registers a "macp" command on the parent', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const macpCmd = program.commands.find((c) => c.name() === 'macp');
|
||||||
|
expect(macpCmd).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers "macp tasks" subcommand group', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
||||||
|
const tasksCmd = macpCmd.commands.find((c) => c.name() === 'tasks');
|
||||||
|
expect(tasksCmd).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers "macp tasks list" subcommand with --status and --type flags', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
||||||
|
const tasksCmd = macpCmd.commands.find((c) => c.name() === 'tasks')!;
|
||||||
|
const listCmd = tasksCmd.commands.find((c) => c.name() === 'list');
|
||||||
|
expect(listCmd).toBeDefined();
|
||||||
|
const optionNames = listCmd!.options.map((o) => o.long);
|
||||||
|
expect(optionNames).toContain('--status');
|
||||||
|
expect(optionNames).toContain('--type');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers "macp submit" subcommand', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
||||||
|
const submitCmd = macpCmd.commands.find((c) => c.name() === 'submit');
|
||||||
|
expect(submitCmd).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers "macp gate" subcommand with --fail-on flag', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
||||||
|
const gateCmd = macpCmd.commands.find((c) => c.name() === 'gate');
|
||||||
|
expect(gateCmd).toBeDefined();
|
||||||
|
const optionNames = gateCmd!.options.map((o) => o.long);
|
||||||
|
expect(optionNames).toContain('--fail-on');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers "macp events" subcommand group', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
||||||
|
const eventsCmd = macpCmd.commands.find((c) => c.name() === 'events');
|
||||||
|
expect(eventsCmd).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers "macp events tail" subcommand', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
||||||
|
const eventsCmd = macpCmd.commands.find((c) => c.name() === 'events')!;
|
||||||
|
const tailCmd = eventsCmd.commands.find((c) => c.name() === 'tail');
|
||||||
|
expect(tailCmd).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has all required top-level subcommands', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
|
||||||
|
const topLevel = macpCmd.commands.map((c) => c.name());
|
||||||
|
expect(topLevel).toContain('tasks');
|
||||||
|
expect(topLevel).toContain('submit');
|
||||||
|
expect(topLevel).toContain('gate');
|
||||||
|
expect(topLevel).toContain('events');
|
||||||
|
});
|
||||||
|
});
|
||||||
92
packages/macp/src/cli.ts
Normal file
92
packages/macp/src/cli.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Command } from 'commander';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register macp subcommands on an existing Commander program.
|
||||||
|
* This avoids cross-package Commander version mismatches by using the
|
||||||
|
* caller's Command instance directly.
|
||||||
|
*/
|
||||||
|
export function registerMacpCommand(parent: Command): void {
|
||||||
|
const macp = parent.command('macp').description('MACP task and gate management');
|
||||||
|
|
||||||
|
// ─── tasks ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const tasks = macp.command('tasks').description('Manage MACP tasks');
|
||||||
|
|
||||||
|
tasks
|
||||||
|
.command('list')
|
||||||
|
.description('List MACP tasks')
|
||||||
|
.option(
|
||||||
|
'--status <status>',
|
||||||
|
'Filter by task status (pending|running|gated|completed|failed|escalated)',
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
'--type <type>',
|
||||||
|
'Filter by task type (coding|deploy|research|review|documentation|infrastructure)',
|
||||||
|
)
|
||||||
|
.action((opts: { status?: string; type?: string }) => {
|
||||||
|
// not yet wired — task persistence layer is not present in @mosaicstack/macp
|
||||||
|
console.log('[macp] tasks list: not yet wired — use macp package programmatically');
|
||||||
|
if (opts.status) {
|
||||||
|
console.log(` status filter: ${opts.status}`);
|
||||||
|
}
|
||||||
|
if (opts.type) {
|
||||||
|
console.log(` type filter: ${opts.type}`);
|
||||||
|
}
|
||||||
|
process.exitCode = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── submit ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
macp
|
||||||
|
.command('submit <path>')
|
||||||
|
.description('Submit a task from a JSON/YAML spec file')
|
||||||
|
.action((specPath: string) => {
|
||||||
|
// not yet wired — task submission requires a running MACP server
|
||||||
|
console.log('[macp] submit: not yet wired — use macp package programmatically');
|
||||||
|
console.log(` spec path: ${specPath}`);
|
||||||
|
console.log(' task id: (unavailable — no MACP server connected)');
|
||||||
|
console.log(' status: (unavailable — no MACP server connected)');
|
||||||
|
process.exitCode = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── gate ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
macp
|
||||||
|
.command('gate <spec>')
|
||||||
|
.description('Run a gate from a spec string or file path (wraps runGate/runGates)')
|
||||||
|
.option('--fail-on <mode>', 'Gate fail-on mode: ai|fail|both|none', 'fail')
|
||||||
|
.option('--cwd <path>', 'Working directory for gate execution', process.cwd())
|
||||||
|
.option('--log <path>', 'Path to write gate log output', '/tmp/macp-gate.log')
|
||||||
|
.option('--timeout <seconds>', 'Gate timeout in seconds', '60')
|
||||||
|
.action((spec: string, opts: { failOn: string; cwd: string; log: string; timeout: string }) => {
|
||||||
|
// not yet wired — gate execution requires a task context and event sink
|
||||||
|
console.log('[macp] gate: not yet wired — use macp package programmatically');
|
||||||
|
console.log(` spec: ${spec}`);
|
||||||
|
console.log(` fail-on: ${opts.failOn}`);
|
||||||
|
console.log(` cwd: ${opts.cwd}`);
|
||||||
|
console.log(` log: ${opts.log}`);
|
||||||
|
console.log(` timeout: ${opts.timeout}s`);
|
||||||
|
process.exitCode = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── events ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const events = macp.command('events').description('Stream MACP events');
|
||||||
|
|
||||||
|
events
|
||||||
|
.command('tail')
|
||||||
|
.description('Tail MACP events from the event log (wraps event emitter)')
|
||||||
|
.option('--file <path>', 'Path to the MACP events NDJSON file')
|
||||||
|
.option('--follow', 'Follow the file for new events (like tail -f)')
|
||||||
|
.action((opts: { file?: string; follow?: boolean }) => {
|
||||||
|
// not yet wired — event streaming requires a live event source
|
||||||
|
console.log('[macp] events tail: not yet wired — use macp package programmatically');
|
||||||
|
if (opts.file) {
|
||||||
|
console.log(` file: ${opts.file}`);
|
||||||
|
}
|
||||||
|
if (opts.follow) {
|
||||||
|
console.log(' mode: follow');
|
||||||
|
}
|
||||||
|
process.exitCode = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -41,3 +41,6 @@ export type { NormalizedGate } from './gate-runner.js';
|
|||||||
|
|
||||||
// Event emitter
|
// Event emitter
|
||||||
export { nowISO, appendEvent, emitEvent } from './event-emitter.js';
|
export { nowISO, appendEvent, emitEvent } from './event-emitter.js';
|
||||||
|
|
||||||
|
// CLI
|
||||||
|
export { registerMacpCommand } from './cli.js';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/memory",
|
"name": "@mosaicstack/memory",
|
||||||
"version": "0.0.3",
|
"version": "0.0.4",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/memory"
|
"directory": "packages/memory"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"@mosaicstack/db": "workspace:*",
|
"@mosaicstack/db": "workspace:*",
|
||||||
"@mosaicstack/storage": "workspace:*",
|
"@mosaicstack/storage": "workspace:*",
|
||||||
"@mosaicstack/types": "workspace:*",
|
"@mosaicstack/types": "workspace:*",
|
||||||
|
"commander": "^13.0.0",
|
||||||
"drizzle-orm": "^0.45.1"
|
"drizzle-orm": "^0.45.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
63
packages/memory/src/cli.spec.ts
Normal file
63
packages/memory/src/cli.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { registerMemoryCommand } from './cli.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke test — only verifies command wiring.
|
||||||
|
* Does NOT open a database connection.
|
||||||
|
*/
|
||||||
|
describe('registerMemoryCommand', () => {
|
||||||
|
function buildProgram(): Command {
|
||||||
|
const program = new Command('mosaic');
|
||||||
|
program.exitOverride(); // prevent process.exit during tests
|
||||||
|
registerMemoryCommand(program);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('registers a "memory" subcommand', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const memory = program.commands.find((c) => c.name() === 'memory');
|
||||||
|
expect(memory).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers "memory search"', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const memory = program.commands.find((c) => c.name() === 'memory')!;
|
||||||
|
const search = memory.commands.find((c) => c.name() === 'search');
|
||||||
|
expect(search).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers "memory stats"', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const memory = program.commands.find((c) => c.name() === 'memory')!;
|
||||||
|
const stats = memory.commands.find((c) => c.name() === 'stats');
|
||||||
|
expect(stats).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers "memory insights list"', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const memory = program.commands.find((c) => c.name() === 'memory')!;
|
||||||
|
const insights = memory.commands.find((c) => c.name() === 'insights');
|
||||||
|
expect(insights).toBeDefined();
|
||||||
|
const list = insights!.commands.find((c) => c.name() === 'list');
|
||||||
|
expect(list).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers "memory preferences list"', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const memory = program.commands.find((c) => c.name() === 'memory')!;
|
||||||
|
const preferences = memory.commands.find((c) => c.name() === 'preferences');
|
||||||
|
expect(preferences).toBeDefined();
|
||||||
|
const list = preferences!.commands.find((c) => c.name() === 'list');
|
||||||
|
expect(list).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"memory search" has --limit and --agent options', () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
const memory = program.commands.find((c) => c.name() === 'memory')!;
|
||||||
|
const search = memory.commands.find((c) => c.name() === 'search')!;
|
||||||
|
const optNames = search.options.map((o) => o.long);
|
||||||
|
expect(optNames).toContain('--limit');
|
||||||
|
expect(optNames).toContain('--agent');
|
||||||
|
});
|
||||||
|
});
|
||||||
179
packages/memory/src/cli.ts
Normal file
179
packages/memory/src/cli.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import type { Command } from 'commander';
|
||||||
|
|
||||||
|
import type { MemoryAdapter } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build and return a connected MemoryAdapter from a connection string or
|
||||||
|
* the MEMORY_DB_URL / DATABASE_URL environment variable.
|
||||||
|
*
|
||||||
|
* For pgvector (postgres://...) the connection string is injected into
|
||||||
|
* DATABASE_URL so that PgVectorAdapter's internal createDb() picks it up.
|
||||||
|
*
|
||||||
|
* Throws with a human-readable message if no connection info is available.
|
||||||
|
*/
|
||||||
|
async function resolveAdapter(dbOption: string | undefined): Promise<MemoryAdapter> {
|
||||||
|
const connStr = dbOption ?? process.env['MEMORY_DB_URL'] ?? process.env['DATABASE_URL'];
|
||||||
|
if (!connStr) {
|
||||||
|
throw new Error(
|
||||||
|
'No database connection string provided. ' +
|
||||||
|
'Pass --db <connection-string> or set MEMORY_DB_URL / DATABASE_URL.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy imports so the module loads cleanly without a live DB during smoke tests.
|
||||||
|
const { createMemoryAdapter, registerMemoryAdapter } = await import('./factory.js');
|
||||||
|
|
||||||
|
if (connStr.startsWith('postgres') || connStr.startsWith('pg')) {
|
||||||
|
// PgVectorAdapter reads DATABASE_URL via createDb() — inject it here.
|
||||||
|
process.env['DATABASE_URL'] = connStr;
|
||||||
|
|
||||||
|
const { PgVectorAdapter } = await import('./adapters/pgvector.js');
|
||||||
|
registerMemoryAdapter('pgvector', (cfg) => new PgVectorAdapter(cfg as never));
|
||||||
|
return createMemoryAdapter({ type: 'pgvector' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyword adapter backed by pglite storage; treat connStr as a data directory.
|
||||||
|
const { KeywordAdapter } = await import('./adapters/keyword.js');
|
||||||
|
const { createStorageAdapter, registerStorageAdapter } = await import('@mosaicstack/storage');
|
||||||
|
const { PgliteAdapter } = await import('@mosaicstack/storage');
|
||||||
|
|
||||||
|
registerStorageAdapter('pglite', (cfg) => new PgliteAdapter(cfg as never));
|
||||||
|
|
||||||
|
const storage = createStorageAdapter({ type: 'pglite', dataDir: connStr });
|
||||||
|
|
||||||
|
registerMemoryAdapter('keyword', (cfg) => new KeywordAdapter(cfg as never));
|
||||||
|
return createMemoryAdapter({ type: 'keyword', storage });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register `memory` subcommands on an existing Commander program.
|
||||||
|
* Follows the registerQualityRails pattern from @mosaicstack/quality-rails.
|
||||||
|
*/
|
||||||
|
export function registerMemoryCommand(parent: Command): void {
|
||||||
|
const memory = parent.command('memory').description('Inspect and query the Mosaic memory layer');
|
||||||
|
|
||||||
|
// ── memory search <query> ──────────────────────────────────────────────
|
||||||
|
memory
|
||||||
|
.command('search <query>')
|
||||||
|
.description('Semantic search over insights')
|
||||||
|
.option('--db <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
|
||||||
|
.option('--limit <n>', 'Maximum number of results', '10')
|
||||||
|
.option('--agent <id>', 'Filter by agent / user ID')
|
||||||
|
.action(async (query: string, opts: { db?: string; limit: string; agent?: string }) => {
|
||||||
|
let adapter: MemoryAdapter | undefined;
|
||||||
|
try {
|
||||||
|
adapter = await resolveAdapter(opts.db);
|
||||||
|
const limit = parseInt(opts.limit, 10);
|
||||||
|
const userId = opts.agent ?? 'system';
|
||||||
|
const results = await adapter.searchInsights(userId, query, { limit });
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
console.log('No insights found.');
|
||||||
|
} else {
|
||||||
|
for (const r of results) {
|
||||||
|
console.log(`[${r.id}] (score=${r.score.toFixed(3)}) ${r.content}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await adapter?.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── memory stats ──────────────────────────────────────────────────────
|
||||||
|
memory
|
||||||
|
.command('stats')
|
||||||
|
.description('Print memory tier info: adapter type, insight count, preference count')
|
||||||
|
.option('--db <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
|
||||||
|
.option('--agent <id>', 'User / agent ID scope for counts', 'system')
|
||||||
|
.action(async (opts: { db?: string; agent: string }) => {
|
||||||
|
let adapter: MemoryAdapter | undefined;
|
||||||
|
try {
|
||||||
|
adapter = await resolveAdapter(opts.db);
|
||||||
|
|
||||||
|
const adapterType = adapter.name;
|
||||||
|
|
||||||
|
const insightCount = await adapter
|
||||||
|
.searchInsights(opts.agent, '', { limit: 100000 })
|
||||||
|
.then((r) => r.length)
|
||||||
|
.catch(() => -1);
|
||||||
|
|
||||||
|
const prefCount = await adapter
|
||||||
|
.listPreferences(opts.agent)
|
||||||
|
.then((r) => r.length)
|
||||||
|
.catch(() => -1);
|
||||||
|
|
||||||
|
console.log(`adapter: ${adapterType}`);
|
||||||
|
console.log(`insights: ${insightCount === -1 ? 'unavailable' : String(insightCount)}`);
|
||||||
|
console.log(`preferences: ${prefCount === -1 ? 'unavailable' : String(prefCount)}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await adapter?.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── memory insights ───────────────────────────────────────────────────
|
||||||
|
const insightsCmd = memory.command('insights').description('Manage insights');
|
||||||
|
|
||||||
|
insightsCmd
|
||||||
|
.command('list')
|
||||||
|
.description('List recent insights')
|
||||||
|
.option('--db <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
|
||||||
|
.option('--limit <n>', 'Maximum number of results', '20')
|
||||||
|
.option('--agent <id>', 'User / agent ID scope', 'system')
|
||||||
|
.action(async (opts: { db?: string; limit: string; agent: string }) => {
|
||||||
|
let adapter: MemoryAdapter | undefined;
|
||||||
|
try {
|
||||||
|
adapter = await resolveAdapter(opts.db);
|
||||||
|
const limit = parseInt(opts.limit, 10);
|
||||||
|
const results = await adapter.searchInsights(opts.agent, '', { limit });
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
console.log('No insights found.');
|
||||||
|
} else {
|
||||||
|
for (const r of results) {
|
||||||
|
console.log(`[${r.id}] ${r.content}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await adapter?.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── memory preferences ────────────────────────────────────────────────
|
||||||
|
const prefsCmd = memory.command('preferences').description('Manage stored preferences');
|
||||||
|
|
||||||
|
prefsCmd
|
||||||
|
.command('list')
|
||||||
|
.description('List stored preferences')
|
||||||
|
.option('--db <connection-string>', 'Database connection string (or set MEMORY_DB_URL)')
|
||||||
|
.option('--agent <id>', 'User / agent ID scope', 'system')
|
||||||
|
.option('--category <cat>', 'Filter by category')
|
||||||
|
.action(async (opts: { db?: string; agent: string; category?: string }) => {
|
||||||
|
let adapter: MemoryAdapter | undefined;
|
||||||
|
try {
|
||||||
|
adapter = await resolveAdapter(opts.db);
|
||||||
|
const prefs = await adapter.listPreferences(opts.agent, opts.category);
|
||||||
|
|
||||||
|
if (prefs.length === 0) {
|
||||||
|
console.log('No preferences found.');
|
||||||
|
} else {
|
||||||
|
for (const p of prefs) {
|
||||||
|
console.log(`[${p.category}] ${p.key} = ${JSON.stringify(p.value)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await adapter?.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { createMemory, type Memory } from './memory.js';
|
export { createMemory, type Memory } from './memory.js';
|
||||||
|
export { registerMemoryCommand } from './cli.js';
|
||||||
export {
|
export {
|
||||||
createPreferencesRepo,
|
createPreferencesRepo,
|
||||||
type PreferencesRepo,
|
type PreferencesRepo,
|
||||||
|
|||||||
60
packages/mosaic/README.md
Normal file
60
packages/mosaic/README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# @mosaicstack/mosaic
|
||||||
|
|
||||||
|
CLI package for the Mosaic self-hosted AI agent platform.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic wizard # First-run setup wizard
|
||||||
|
mosaic gateway install # Install the gateway daemon
|
||||||
|
mosaic config show # View current configuration
|
||||||
|
mosaic config hooks list # Manage Claude hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Headless / CI Installation
|
||||||
|
|
||||||
|
Set `MOSAIC_ASSUME_YES=1` (or ensure stdin is not a TTY) to skip all interactive prompts. The following environment variables control the install:
|
||||||
|
|
||||||
|
### Gateway configuration (`mosaic gateway install`)
|
||||||
|
|
||||||
|
| Variable | Default | Required |
|
||||||
|
| -------------------------- | ----------------------- | ------------------ |
|
||||||
|
| `MOSAIC_STORAGE_TIER` | `local` | No |
|
||||||
|
| `MOSAIC_GATEWAY_PORT` | `14242` | No |
|
||||||
|
| `MOSAIC_DATABASE_URL` | _(none)_ | Yes if tier=`team` |
|
||||||
|
| `MOSAIC_VALKEY_URL` | _(none)_ | Yes if tier=`team` |
|
||||||
|
| `MOSAIC_ANTHROPIC_API_KEY` | _(none)_ | No |
|
||||||
|
| `MOSAIC_CORS_ORIGIN` | `http://localhost:3000` | No |
|
||||||
|
|
||||||
|
### Admin user bootstrap
|
||||||
|
|
||||||
|
| Variable | Default | Required |
|
||||||
|
| ----------------------- | -------- | -------------- |
|
||||||
|
| `MOSAIC_ADMIN_NAME` | _(none)_ | Yes (headless) |
|
||||||
|
| `MOSAIC_ADMIN_EMAIL` | _(none)_ | Yes (headless) |
|
||||||
|
| `MOSAIC_ADMIN_PASSWORD` | _(none)_ | Yes (headless) |
|
||||||
|
|
||||||
|
`MOSAIC_ADMIN_PASSWORD` must be at least 8 characters. In headless mode a missing or too-short password causes a non-zero exit.
|
||||||
|
|
||||||
|
### Example: Docker / CI install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MOSAIC_ASSUME_YES=1
|
||||||
|
export MOSAIC_ADMIN_NAME="Admin"
|
||||||
|
export MOSAIC_ADMIN_EMAIL="admin@example.com"
|
||||||
|
export MOSAIC_ADMIN_PASSWORD="securepass123"
|
||||||
|
|
||||||
|
mosaic gateway install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hooks management
|
||||||
|
|
||||||
|
After running `mosaic wizard`, Claude hooks are installed in `~/.claude/hooks-config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic config hooks list # Show all hooks and enabled/disabled status
|
||||||
|
mosaic config hooks disable PostToolUse # Disable a hook (reversible)
|
||||||
|
mosaic config hooks enable PostToolUse # Re-enable a disabled hook
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `CLAUDE_HOME` to override the default `~/.claude` directory.
|
||||||
@@ -17,6 +17,7 @@ import { runWizard } from '../../src/wizard.js';
|
|||||||
describe('Full Wizard (headless)', () => {
|
describe('Full Wizard (headless)', () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
const repoRoot = join(import.meta.dirname, '..', '..');
|
const repoRoot = join(import.meta.dirname, '..', '..');
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-'));
|
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-'));
|
||||||
@@ -32,12 +33,16 @@ describe('Full Wizard (headless)', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rmSync(tmpDir, { recursive: true, force: true });
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
process.env = { ...originalEnv };
|
||||||
});
|
});
|
||||||
|
|
||||||
it('quick start produces valid SOUL.md', async () => {
|
it('quick start produces valid SOUL.md', async () => {
|
||||||
|
// The headless path reads agent name from MOSAIC_AGENT_NAME env var
|
||||||
|
// (via agentIntentStage) rather than prompting interactively.
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'TestBot';
|
||||||
|
|
||||||
const prompter = new HeadlessPrompter({
|
const prompter = new HeadlessPrompter({
|
||||||
'Installation mode': 'quick',
|
'Installation mode': 'quick',
|
||||||
'What name should agents use?': 'TestBot',
|
|
||||||
'Communication style': 'direct',
|
'Communication style': 'direct',
|
||||||
'Your name': 'Tester',
|
'Your name': 'Tester',
|
||||||
'Your pronouns': 'They/Them',
|
'Your pronouns': 'They/Them',
|
||||||
@@ -49,6 +54,7 @@ describe('Full Wizard (headless)', () => {
|
|||||||
sourceDir: tmpDir,
|
sourceDir: tmpDir,
|
||||||
prompter,
|
prompter,
|
||||||
configService: createConfigService(tmpDir, tmpDir),
|
configService: createConfigService(tmpDir, tmpDir),
|
||||||
|
skipGateway: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const soulPath = join(tmpDir, 'SOUL.md');
|
const soulPath = join(tmpDir, 'SOUL.md');
|
||||||
@@ -61,9 +67,10 @@ describe('Full Wizard (headless)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('quick start produces valid USER.md', async () => {
|
it('quick start produces valid USER.md', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'TestBot';
|
||||||
|
|
||||||
const prompter = new HeadlessPrompter({
|
const prompter = new HeadlessPrompter({
|
||||||
'Installation mode': 'quick',
|
'Installation mode': 'quick',
|
||||||
'What name should agents use?': 'TestBot',
|
|
||||||
'Communication style': 'direct',
|
'Communication style': 'direct',
|
||||||
'Your name': 'Tester',
|
'Your name': 'Tester',
|
||||||
'Your pronouns': 'He/Him',
|
'Your pronouns': 'He/Him',
|
||||||
@@ -75,6 +82,7 @@ describe('Full Wizard (headless)', () => {
|
|||||||
sourceDir: tmpDir,
|
sourceDir: tmpDir,
|
||||||
prompter,
|
prompter,
|
||||||
configService: createConfigService(tmpDir, tmpDir),
|
configService: createConfigService(tmpDir, tmpDir),
|
||||||
|
skipGateway: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userPath = join(tmpDir, 'USER.md');
|
const userPath = join(tmpDir, 'USER.md');
|
||||||
@@ -97,6 +105,7 @@ describe('Full Wizard (headless)', () => {
|
|||||||
sourceDir: tmpDir,
|
sourceDir: tmpDir,
|
||||||
prompter,
|
prompter,
|
||||||
configService: createConfigService(tmpDir, tmpDir),
|
configService: createConfigService(tmpDir, tmpDir),
|
||||||
|
skipGateway: true,
|
||||||
cliOverrides: {
|
cliOverrides: {
|
||||||
soul: {
|
soul: {
|
||||||
agentName: 'FromCLI',
|
agentName: 'FromCLI',
|
||||||
|
|||||||
146
packages/mosaic/__tests__/integration/unified-wizard.test.ts
Normal file
146
packages/mosaic/__tests__/integration/unified-wizard.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Unified wizard integration test — exercises the `skipGateway: false` code
|
||||||
|
* path so that wiring between `runWizard` and the two gateway stages is
|
||||||
|
* covered. The gateway stages themselves are mocked (they require a real
|
||||||
|
* daemon + network) but the dynamic imports and option plumbing are real.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, cpSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||||
|
import { createConfigService } from '../../src/config/config-service.js';
|
||||||
|
|
||||||
|
const gatewayConfigMock = vi.fn();
|
||||||
|
const gatewayBootstrapMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../src/stages/gateway-config.js', () => ({
|
||||||
|
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
|
||||||
|
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import AFTER the mocks so runWizard picks up the mocked stage modules.
|
||||||
|
import { runWizard } from '../../src/wizard.js';
|
||||||
|
|
||||||
|
describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
const repoRoot = join(import.meta.dirname, '..', '..');
|
||||||
|
|
||||||
|
const originalIsTTY = process.stdin.isTTY;
|
||||||
|
const originalAssumeYes = process.env['MOSAIC_ASSUME_YES'];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-unified-wizard-'));
|
||||||
|
const candidates = [join(repoRoot, 'framework', 'templates'), join(repoRoot, 'templates')];
|
||||||
|
for (const templatesDir of candidates) {
|
||||||
|
if (existsSync(templatesDir)) {
|
||||||
|
cpSync(templatesDir, join(tmpDir, 'templates'), { recursive: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gatewayConfigMock.mockReset();
|
||||||
|
gatewayBootstrapMock.mockReset();
|
||||||
|
// Pretend we're on an interactive TTY so the wizard's headless-abort
|
||||||
|
// branch does not call `process.exit(1)` during these tests.
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||||
|
delete process.env['MOSAIC_ASSUME_YES'];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', {
|
||||||
|
value: originalIsTTY,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
if (originalAssumeYes === undefined) {
|
||||||
|
delete process.env['MOSAIC_ASSUME_YES'];
|
||||||
|
} else {
|
||||||
|
process.env['MOSAIC_ASSUME_YES'] = originalAssumeYes;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes the gateway config + bootstrap stages by default', async () => {
|
||||||
|
gatewayConfigMock.mockResolvedValue({ ready: true, host: 'localhost', port: 14242 });
|
||||||
|
gatewayBootstrapMock.mockResolvedValue({ completed: true });
|
||||||
|
|
||||||
|
const prompter = new HeadlessPrompter({
|
||||||
|
'Installation mode': 'quick',
|
||||||
|
'What name should agents use?': 'TestBot',
|
||||||
|
'Communication style': 'direct',
|
||||||
|
'Your name': 'Tester',
|
||||||
|
'Your pronouns': 'They/Them',
|
||||||
|
'Your timezone': 'UTC',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runWizard({
|
||||||
|
mosaicHome: tmpDir,
|
||||||
|
sourceDir: tmpDir,
|
||||||
|
prompter,
|
||||||
|
configService: createConfigService(tmpDir, tmpDir),
|
||||||
|
gatewayHost: 'localhost',
|
||||||
|
gatewayPort: 14242,
|
||||||
|
skipGatewayNpmInstall: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(gatewayBootstrapMock).toHaveBeenCalledTimes(1);
|
||||||
|
const configCall = gatewayConfigMock.mock.calls[0];
|
||||||
|
expect(configCall[2]).toMatchObject({
|
||||||
|
host: 'localhost',
|
||||||
|
defaultPort: 14242,
|
||||||
|
skipInstall: true,
|
||||||
|
});
|
||||||
|
const bootstrapCall = gatewayBootstrapMock.mock.calls[0];
|
||||||
|
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not invoke bootstrap when config stage reports not ready', async () => {
|
||||||
|
gatewayConfigMock.mockResolvedValue({ ready: false });
|
||||||
|
|
||||||
|
const prompter = new HeadlessPrompter({
|
||||||
|
'Installation mode': 'quick',
|
||||||
|
'What name should agents use?': 'TestBot',
|
||||||
|
'Communication style': 'direct',
|
||||||
|
'Your name': 'Tester',
|
||||||
|
'Your pronouns': 'They/Them',
|
||||||
|
'Your timezone': 'UTC',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runWizard({
|
||||||
|
mosaicHome: tmpDir,
|
||||||
|
sourceDir: tmpDir,
|
||||||
|
prompter,
|
||||||
|
configService: createConfigService(tmpDir, tmpDir),
|
||||||
|
skipGatewayNpmInstall: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects skipGateway: true', async () => {
|
||||||
|
const prompter = new HeadlessPrompter({
|
||||||
|
'Installation mode': 'quick',
|
||||||
|
'What name should agents use?': 'TestBot',
|
||||||
|
'Communication style': 'direct',
|
||||||
|
'Your name': 'Tester',
|
||||||
|
'Your pronouns': 'They/Them',
|
||||||
|
'Your timezone': 'UTC',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runWizard({
|
||||||
|
mosaicHome: tmpDir,
|
||||||
|
sourceDir: tmpDir,
|
||||||
|
prompter,
|
||||||
|
configService: createConfigService(tmpDir, tmpDir),
|
||||||
|
skipGateway: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(gatewayConfigMock).not.toHaveBeenCalled();
|
||||||
|
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -151,11 +151,68 @@ When delegating work to subagents, you MUST select the cheapest model capable of
|
|||||||
|
|
||||||
**Runtime-specific syntax**: See the runtime reference for how to specify model tier when spawning subagents (e.g., Claude Code Task tool `model` parameter).
|
**Runtime-specific syntax**: See the runtime reference for how to specify model tier when spawning subagents (e.g., Claude Code Task tool `model` parameter).
|
||||||
|
|
||||||
|
## Superpowers Enforcement (Hard Rule)
|
||||||
|
|
||||||
|
Mosaic provides capabilities beyond basic code editing: **skills**, **hooks**, **MCP tools**, and **plugins**. These are not optional extras — they are force multipliers that agents MUST actively use when applicable. Under-utilization of superpowers is a framework violation.
|
||||||
|
|
||||||
|
### Skills
|
||||||
|
|
||||||
|
Skills are domain-specific instruction sets in `~/.config/mosaic/skills/` that encode best practices, patterns, and guardrails. They are loaded into agents via the runtime's skill mechanism (e.g., Claude Code slash commands, Pi `--skill` flag).
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
1. Before starting implementation, scan available skills (`ls ~/.config/mosaic/skills/`) and load any that match the task domain.
|
||||||
|
2. When a skill exists for the technology being used (e.g., `nestjs-best-practices` for NestJS work), you MUST load it.
|
||||||
|
3. When spawning workers, include skill loading in the kickstart prompt.
|
||||||
|
4. If you complete a task without loading a relevant available skill, that is a quality gap.
|
||||||
|
|
||||||
|
### Hooks
|
||||||
|
|
||||||
|
Hooks provide automated quality gates (lint, format, typecheck) that fire on file edits. They are configured in the runtime settings and run automatically.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
1. Do NOT bypass or suppress hook output. If a hook reports errors, fix them before proceeding.
|
||||||
|
2. Hook failures are immediate feedback — treat them like failing tests.
|
||||||
|
3. If a hook is consistently failing on valid code, report it as a framework issue rather than working around it.
|
||||||
|
|
||||||
|
### MCP Tools
|
||||||
|
|
||||||
|
MCP servers extend agent capabilities with external integrations (sequential-thinking, web search, memory, browser automation, etc.). Available MCP tools are listed at session start.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
1. **sequential-thinking** is REQUIRED for planning, architecture, and multi-step reasoning. Use it — do not skip structured thinking for complex decisions.
|
||||||
|
2. **OpenBrain** (`capture`, `search`, `recent`) is the cross-agent memory layer. Capture discoveries and search for prior context at session start.
|
||||||
|
3. When a task involves web research, browser testing, or external data, use the available MCP tools (web-search, chrome-devtools, web-reader) rather than asking the user to look things up.
|
||||||
|
4. Check available MCP tools at session start and use them proactively throughout the session.
|
||||||
|
|
||||||
|
### Plugins (Runtime-Specific)
|
||||||
|
|
||||||
|
Runtime plugins (e.g., Claude Code's `feature-dev`, `pr-review-toolkit`, `code-review`) provide specialized agent capabilities like code review, architecture analysis, and test coverage analysis.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
1. After completing a significant code change, use code review plugins proactively — do not wait for the user to ask.
|
||||||
|
2. Before creating a PR, use PR review plugins to catch issues early.
|
||||||
|
3. When designing architecture, use planning/architecture plugins for structured analysis.
|
||||||
|
|
||||||
|
### Self-Evolution
|
||||||
|
|
||||||
|
The Mosaic framework should improve over time based on usage patterns:
|
||||||
|
|
||||||
|
1. When you discover a recurring pattern that should be codified, capture it to OpenBrain with `type: "framework-improvement"`.
|
||||||
|
2. When a hook, skill, or tool is missing for a common task, capture the gap to OpenBrain with `type: "tooling-gap"`.
|
||||||
|
3. When a framework rule causes friction without adding value, capture the observation to OpenBrain with `type: "framework-friction"`.
|
||||||
|
|
||||||
|
These captures feed the framework's continuous improvement cycle.
|
||||||
|
|
||||||
## Skills Policy
|
## Skills Policy
|
||||||
|
|
||||||
- Use only the minimum required skills for the active task.
|
- Load skills that match the active task domain before starting implementation.
|
||||||
- Do not load unrelated skills.
|
- Do not load unrelated skills.
|
||||||
- Follow skill trigger rules from the active runtime instruction layer.
|
- Follow skill trigger rules from the active runtime instruction layer.
|
||||||
|
- Actively check `~/.config/mosaic/skills/` for applicable skills rather than passively waiting for them to be mentioned.
|
||||||
|
|
||||||
## Session Closure Requirement
|
## Session Closure Requirement
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user