#!/usr/bin/env node

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  ListPromptsRequestSchema,
  ListResourcesRequestSchema,
  ListResourceTemplatesRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { z } from "zod";
import { searchLibraries, fetchLibraryContext } from "./lib/api.js";
import type { ClientContext } from "./lib/types.js";
import { formatSearchResults, extractClientInfoFromUserAgent } from "./lib/utils.js";
import { isJWT, validateJWT } from "./lib/jwt.js";
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { Command } from "commander";
import { AsyncLocalStorage } from "async_hooks";
import { randomUUID } from "node:crypto";
import { createSessionStore } from "./lib/sessionStore.js";
import {
  SERVER_VERSION,
  RESOURCE_URL,
  AUTH_SERVER_URL,
  OPENAI_APPS_CHALLENGE_TOKEN,
} from "./lib/constants.js";
import { maybeElicitAuthSignIn } from "./lib/auth/auth-prompt.js";
import { getClientIp } from "./lib/client-ip.js";

/** Default HTTP server port */
const DEFAULT_PORT = 3000;

// Parse CLI arguments using commander
const program = new Command()
  .version(SERVER_VERSION, "-v, --version", "output the current version")
  .option("--transport <stdio|http>", "transport type", "stdio")
  .option("--port <number>", "port for HTTP transport", DEFAULT_PORT.toString())
  .option("--api-key <key>", "API key for authentication (or set CONTEXT7_API_KEY env var)")
  .allowUnknownOption() // let MCP Inspector / other wrappers pass through extra flags
  .parse(process.argv);

const cliOptions = program.opts<{
  transport: string;
  port: string;
  apiKey?: string;
}>();

// Validate transport option
const allowedTransports = ["stdio", "http"];
if (!allowedTransports.includes(cliOptions.transport)) {
  console.error(
    `Invalid --transport value: '${cliOptions.transport}'. Must be one of: stdio, http.`
  );
  process.exit(1);
}

// Transport configuration
const TRANSPORT_TYPE = (cliOptions.transport || "stdio") as "stdio" | "http";

// Disallow incompatible flags based on transport
const passedPortFlag = process.argv.includes("--port");
const passedApiKeyFlag = process.argv.includes("--api-key");

if (TRANSPORT_TYPE === "http" && passedApiKeyFlag) {
  console.error(
    "The --api-key flag is not allowed when using --transport http. Use header-based auth at the HTTP layer instead."
  );
  process.exit(1);
}

if (TRANSPORT_TYPE === "stdio" && passedPortFlag) {
  console.error("The --port flag is not allowed when using --transport stdio.");
  process.exit(1);
}

// HTTP port configuration
const CLI_PORT = (() => {
  const parsed = parseInt(cliOptions.port, 10);
  return isNaN(parsed) ? undefined : parsed;
})();

const requestContext = new AsyncLocalStorage<ClientContext>();

// Global state for stdio mode only
let stdioApiKey: string | undefined;
let stdioClientInfo: { ide?: string; version?: string } | undefined;
// One session ID per stdio process.
let stdioSessionId: string | undefined;

/**
 * Get the effective client context
 */
function getClientContext(): ClientContext {
  const ctx = requestContext.getStore();

  // HTTP mode: context is fully populated from request
  if (ctx) {
    return ctx;
  }

  // stdio mode: use globals
  return {
    apiKey: stdioApiKey,
    clientInfo: stdioClientInfo,
    transport: "stdio",
    sessionId: stdioSessionId,
  };
}

function createMcpServer() {
  const server = new McpServer(
    {
      name: "Context7",
      version: SERVER_VERSION,
      websiteUrl: "https://context7.com",
      description:
        "Context7 provides up-to-date documentation and code examples for libraries and frameworks.",
      icons: [
        {
          src: "https://context7.com/context7-icon-green.png",
          mimeType: "image/png",
        },
      ],
    },
    {
      instructions: `Use this server to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service — even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer — your training data may not reflect recent changes. Prefer this over web search for library docs.

Do not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general programming concepts.`,
    }
  );

  server.registerTool(
    "resolve-library-id",
    {
      title: "Resolve Context7 Library ID",
      description: `Resolves a package/product name to a Context7-compatible library ID and returns matching libraries.

You MUST call this function before 'Query Documentation' tool to obtain a valid Context7-compatible library ID UNLESS the user explicitly provides a library ID in the format '/org/project' or '/org/project/version' in their query.

Each result includes:
- Library ID: Context7-compatible identifier (format: /org/project)
- Name: Library or package name
- Description: Short summary
- Code Snippets: Number of available code examples
- Source Reputation: Authority indicator (High, Medium, Low, or Unknown)
- Benchmark Score: Quality indicator (100 is the highest score)
- Versions: List of versions if available. Use one of those versions if the user provides a version in their query. The format of the version is /org/project/version.

For best results, select libraries based on name match, source reputation, snippet coverage, benchmark score, and relevance to your use case.

Selection Process:
1. Analyze the query to understand what library/package the user is looking for
2. Return the most relevant match based on:
- Name similarity to the query (exact matches prioritized)
- Description relevance to the query's intent
- Documentation coverage (prioritize libraries with higher Code Snippet counts)
- Source reputation (consider libraries with High or Medium reputation more authoritative)
- Benchmark Score: Quality indicator (100 is the highest score)

Response Format:
- Return the selected library ID in a clearly marked section
- Provide a brief explanation for why this library was chosen
- If multiple good matches exist, acknowledge this but proceed with the most relevant one
- If no good matches exist, clearly state this and suggest query refinements

For ambiguous queries, request clarification before proceeding with a best-guess match.

IMPORTANT: Do not call this tool more than 3 times per question. If you cannot find what you need after 3 calls, use the best result you have.`,
      inputSchema: {
        query: z
          .string()
          .describe(
            "What to look up in the library's documentation. This is used to rank library results by relevance to what the user is trying to accomplish. The query is sent to the Context7 API for processing. Do not include any sensitive or confidential information such as API keys, passwords, credentials, personal data, or proprietary code in your query."
          ),
        libraryName: z
          .string()
          .describe(
            "Library name to search for and retrieve a Context7-compatible library ID. Use the official library name with proper punctuation — e.g., 'Next.js' instead of 'nextjs', 'Customer.io' instead of 'customerio', 'Three.js' instead of 'threejs'."
          ),
      },
      annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        openWorldHint: true,
        idempotentHint: true,
      },
    },
    async ({ query, libraryName }: { query: string; libraryName: string }) => {
      const ctx = getClientContext();
      const searchResponse = await searchLibraries(query, libraryName, ctx);

      if (!searchResponse.results || searchResponse.results.length === 0) {
        const text = searchResponse.error ?? "No libraries found matching the provided name.";
        maybeElicitAuthSignIn(server, ctx);
        return {
          content: [
            {
              type: "text",
              text,
            },
          ],
        };
      }

      const resultsText = formatSearchResults(searchResponse);
      const responseText = `Available Libraries:\n\n${resultsText}`;
      maybeElicitAuthSignIn(server, ctx);
      return {
        content: [
          {
            type: "text",
            text: responseText,
          },
        ],
      };
    }
  );

  server.registerTool(
    "query-docs",
    {
      title: "Query Documentation",
      description: `Retrieves and queries up-to-date documentation and code examples from Context7 for any programming library or framework.

You must call 'Resolve Context7 Library ID' tool first to obtain the exact Context7-compatible library ID required to use this tool, UNLESS the user explicitly provides a library ID in the format '/org/project' or '/org/project/version' in their query.

Do not call this tool more than 3 times per question.`,
      inputSchema: {
        libraryId: z
          .string()
          .describe(
            "Exact Context7-compatible library ID (e.g., '/mongodb/docs', '/vercel/next.js', '/supabase/supabase', '/vercel/next.js/v14.3.0-canary.87') retrieved from 'resolve-library-id' or directly from user query in the format '/org/project' or '/org/project/version'."
          ),
        query: z
          .string()
          .describe(
            "What to look up in the library's documentation, scoped to a single concept. Be specific and include relevant details, but keep each query to one topic — if the user's question spans multiple distinct concepts, make a separate call per concept instead of combining them, unless the question is about how the concepts interact. Good: 'How to set up authentication with JWT in Express.js' or 'React useEffect cleanup function examples'. Bad (too vague): 'auth' or 'hooks'. Bad (too broad): 'routing and auth and caching in Next.js'. The query is sent to the Context7 API for processing. Do not include any sensitive or confidential information such as API keys, passwords, credentials, personal data, or proprietary code in your query."
          ),
      },
      annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        openWorldHint: true,
        idempotentHint: true,
      },
    },
    async ({ query, libraryId }: { query: string; libraryId: string }) => {
      const ctx = getClientContext();
      const response = await fetchLibraryContext({ query, libraryId }, ctx);
      maybeElicitAuthSignIn(server, ctx);
      return {
        content: [
          {
            type: "text",
            text: response.data,
          },
        ],
      };
    }
  );

  server.server.registerCapabilities({ prompts: {}, resources: {} });
  server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
  server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
    resources: [],
  }));
  server.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
    resourceTemplates: [],
  }));

  return server;
}

// Map of canonical arg name -> hallucinated aliases that should be rewritten
// to it. LLM clients often echo phrasing from tool descriptions instead of
// the literal schema keys, which trips Zod validation before the tool runs.
type AliasMap = Record<string, readonly string[]>;

const GLOBAL_ALIASES: AliasMap = {
  query: ["userQuery", "question"],
};

// Tool-scoped aliases, for keys that are canonical on one tool but a
// hallucination on another (e.g. `libraryName` is canonical for
// `resolve-library-id`, so we only rewrite it on `query-docs` calls).
const TOOL_ALIASES: Record<string, AliasMap> = {
  "query-docs": {
    libraryId: ["context7CompatibleLibraryID", "libraryID", "libraryName"],
  },
};

function applyAliases(args: Record<string, unknown>, aliases: AliasMap): void {
  for (const [canonical, alternatives] of Object.entries(aliases)) {
    if (canonical in args) continue;
    for (const alt of alternatives) {
      if (alt in args) {
        args[canonical] = args[alt];
        delete args[alt];
        break;
      }
    }
  }
}

// Install BEFORE `server.connect(transport)`: the SDK's `Protocol.connect()`
// captures the existing `onmessage` and chains its dispatch handler over it,
// so our hook runs first on every incoming JSON-RPC message.
function installTransportArgAliasing(transport: Transport): void {
  transport.onmessage = (message) => {
    const msg = message as {
      method?: string;
      params?: { name?: string; arguments?: unknown };
    };
    if (msg.method !== "tools/call") return;
    const args = msg.params?.arguments;
    if (!args || typeof args !== "object") return;
    const argsRecord = args as Record<string, unknown>;

    applyAliases(argsRecord, GLOBAL_ALIASES);

    const toolName = msg.params?.name;
    if (toolName && toolName in TOOL_ALIASES) {
      applyAliases(argsRecord, TOOL_ALIASES[toolName]);
    }
  };
}

async function main() {
  const transportType = TRANSPORT_TYPE;

  if (transportType === "http") {
    const initialPort = CLI_PORT ?? DEFAULT_PORT;

    const app = express();
    app.use(express.json());

    app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
      res.setHeader("Access-Control-Allow-Origin", "*");
      res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,DELETE");
      res.setHeader(
        "Access-Control-Allow-Headers",
        "Content-Type, MCP-Session-Id, MCP-Protocol-Version, X-Context7-API-Key, Context7-API-Key, X-API-Key, Authorization"
      );
      res.setHeader("Access-Control-Expose-Headers", "MCP-Session-Id");

      if (req.method === "OPTIONS") {
        res.sendStatus(200);
        return;
      }
      next();
    });

    const extractHeaderValue = (value: string | string[] | undefined): string | undefined => {
      if (!value) return undefined;
      return typeof value === "string" ? value : value[0];
    };

    const extractBearerToken = (authHeader: string | string[] | undefined): string | undefined => {
      const header = extractHeaderValue(authHeader);
      if (!header) return undefined;

      if (header.startsWith("Bearer ")) {
        return header.substring(7).trim();
      }

      return header;
    };

    const extractApiKey = (req: express.Request): string | undefined => {
      return (
        extractBearerToken(req.headers.authorization) ||
        extractHeaderValue(req.headers["context7-api-key"]) ||
        extractHeaderValue(req.headers["x-api-key"]) ||
        extractHeaderValue(req.headers["context7_api_key"]) ||
        extractHeaderValue(req.headers["x_api_key"])
      );
    };

    const sessionStore = createSessionStore();

    const handleMcpRequest = async (
      req: express.Request,
      res: express.Response,
      requireAuth: boolean
    ) => {
      // Reject GET requests — sessions are tracked in Redis, but this server does not send
      // server-initiated notifications, so SSE streams serve no purpose and cause mass NGINX
      // timeouts. Returning 405 is spec-compliant per MCP StreamableHTTP (2025-03-26).
      if (req.method === "GET") {
        return res.status(405).json({
          jsonrpc: "2.0",
          error: { code: -32000, message: "Server does not support GET requests" },
          id: null,
        });
      }

      try {
        const apiKey = extractApiKey(req);
        const resourceUrl = RESOURCE_URL;
        const baseUrl = new URL(resourceUrl).origin;

        // OAuth discovery info header, used by MCP clients to discover the authorization server
        res.set(
          "WWW-Authenticate",
          `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`
        );

        if (requireAuth) {
          if (!apiKey) {
            return res.status(401).json({
              jsonrpc: "2.0",
              error: {
                code: -32001,
                message: "Authentication required. Please authenticate to use this MCP server.",
              },
              id: null,
            });
          }

          if (isJWT(apiKey)) {
            const validationResult = await validateJWT(apiKey);
            if (!validationResult.valid) {
              return res.status(401).json({
                jsonrpc: "2.0",
                error: {
                  code: -32001,
                  message: validationResult.error || "Invalid token. Please re-authenticate.",
                },
                id: null,
              });
            }
          }
        }

        const context: ClientContext = {
          clientIp: getClientIp(req),
          apiKey: apiKey,
          clientInfo: extractClientInfoFromUserAgent(req.headers["user-agent"]),
          transport: "http",
        };

        const sessionId = extractHeaderValue(req.headers["mcp-session-id"]);

        if (req.method === "DELETE") {
          if (!sessionId) {
            return res.status(400).json({
              jsonrpc: "2.0",
              error: { code: -32000, message: "Bad Request: No valid session ID provided" },
              id: null,
            });
          }
          await sessionStore.delete(sessionId);
          return res.status(200).end();
        }

        let effectiveSessionId: string;
        if (!sessionId && req.method === "POST" && isInitializeRequest(req.body)) {
          effectiveSessionId = randomUUID();
          await sessionStore.create(effectiveSessionId);
          res.setHeader("mcp-session-id", effectiveSessionId);
        } else if (sessionId && req.method === "POST" && !isInitializeRequest(req.body)) {
          const sessionExists = await sessionStore.refresh(sessionId);
          if (!sessionExists) {
            // Per MCP Streamable HTTP spec: 404 signals to the client that the session
            // has been terminated/expired, so it should re-initialize with a fresh InitializeRequest.
            return res.status(404).json({
              jsonrpc: "2.0",
              error: {
                code: -32000,
                message: "Session not found or expired. Please re-initialize.",
              },
              id: null,
            });
          }
          effectiveSessionId = sessionId;
        } else {
          return res.status(400).json({
            jsonrpc: "2.0",
            error: { code: -32000, message: "Bad Request: No valid session ID provided" },
            id: null,
          });
        }

        context.sessionId = effectiveSessionId;

        // sessionIdGenerator is undefined because session lifecycle (create/refresh/delete)
        // is owned by the route handler above and persisted in Redis, not by the SDK transport.
        //
        // Use SSE responses for tool calls (enableJsonResponse: false). The SDK then
        // flushes response headers immediately after parsing the request rather than
        // buffering until the tool returns. This is required for long-running tools
        // because some MCP HTTP clients cap the underlying fetch at 60s waiting for
        // headers, even though the per-tool timeout is much higher.
        const transport = new StreamableHTTPServerTransport({
          sessionIdGenerator: undefined,
          enableJsonResponse: false,
        });

        const server = createMcpServer();
        res.on("close", () => {
          transport.close();
          server.close();
        });

        installTransportArgAliasing(transport);
        await server.connect(transport);

        await requestContext.run(context, async () => {
          await transport.handleRequest(req, res, req.body);
        });
      } catch (error) {
        console.error("Error handling MCP request:", error);
        if (!res.headersSent) {
          res.status(500).json({
            jsonrpc: "2.0",
            error: { code: -32603, message: "Internal server error" },
            id: null,
          });
        }
      }
    };

    // Anonymous access endpoint - no authentication required
    app.all("/mcp", async (req, res) => {
      await handleMcpRequest(req, res, false);
    });

    // OAuth-protected endpoint - requires authentication
    app.all("/mcp/oauth", async (req, res) => {
      await handleMcpRequest(req, res, true);
    });

    app.get("/ping", (_req: express.Request, res: express.Response) => {
      res.json({ status: "ok", message: "pong" });
    });

    // OAuth 2.0 Protected Resource Metadata (RFC 9728)
    // Used by MCP clients to discover the authorization server
    app.get(
      "/.well-known/oauth-protected-resource",
      (_req: express.Request, res: express.Response) => {
        res.json({
          resource: RESOURCE_URL,
          authorization_servers: [AUTH_SERVER_URL],
          scopes_supported: ["profile", "email"],
          bearer_methods_supported: ["header"],
        });
      }
    );

    app.get(
      "/.well-known/oauth-authorization-server",
      async (_req: express.Request, res: express.Response) => {
        const authServerUrl = AUTH_SERVER_URL;

        try {
          const response = await fetch(`${authServerUrl}/.well-known/oauth-authorization-server`);
          if (!response.ok) {
            console.error("[OAuth] Upstream error:", response.status);
            return res.status(response.status).json({
              error: "upstream_error",
              message: "Failed to fetch authorization server metadata",
            });
          }
          const metadata = await response.json();
          res.json(metadata);
        } catch (error) {
          console.error("[OAuth] Error fetching OAuth metadata:", error);
          res.status(502).json({
            error: "proxy_error",
            message: "Failed to proxy authorization server metadata",
          });
        }
      }
    );

    // OpenAI Apps SDK domain verification challenge
    app.get(
      "/.well-known/openai-apps-challenge",
      (_req: express.Request, res: express.Response) => {
        if (!OPENAI_APPS_CHALLENGE_TOKEN) {
          return res.status(404).json({
            error: "not_found",
            message: "Endpoint not found.",
          });
        }
        res.type("text/plain").send(OPENAI_APPS_CHALLENGE_TOKEN);
      }
    );

    // Catch-all 404 handler - must be after all other routes
    app.use((_req: express.Request, res: express.Response) => {
      res.status(404).json({
        error: "not_found",
        message: "Endpoint not found. Use /mcp for MCP protocol communication.",
      });
    });

    const startServer = (port: number, maxAttempts = 10) => {
      const httpServer = app.listen(port);

      httpServer.once("error", (err: NodeJS.ErrnoException) => {
        if (err.code === "EADDRINUSE" && port < initialPort + maxAttempts) {
          console.warn(`Port ${port} is in use, trying port ${port + 1}...`);
          startServer(port + 1, maxAttempts);
        } else {
          console.error(`Failed to start server: ${err.message}`);
          process.exit(1);
        }
      });

      httpServer.once("listening", () => {
        console.error(
          `Context7 Documentation MCP Server v${SERVER_VERSION} running on HTTP at http://localhost:${port}/mcp`
        );
      });
    };

    startServer(initialPort);
  } else {
    stdioApiKey = cliOptions.apiKey || process.env.CONTEXT7_API_KEY;
    stdioSessionId = randomUUID();

    process.stdin.on("end", () => process.exit(0));
    process.stdin.on("close", () => process.exit(0));
    process.on("SIGHUP", () => process.exit(0));

    const transport = new StdioServerTransport();
    const server = createMcpServer();

    // Capture client info from MCP initialize handshake (stdio only — HTTP
    // mode plumbs client info through requestContext per request).
    server.server.oninitialized = () => {
      const clientVersion = server.server.getClientVersion();
      if (clientVersion) {
        stdioClientInfo = {
          ide: clientVersion.name,
          version: clientVersion.version,
        };
      }
    };

    installTransportArgAliasing(transport);
    await server.connect(transport);

    console.error(`Context7 Documentation MCP Server v${SERVER_VERSION} running on stdio`);
  }
}

main().catch((error) => {
  console.error("Fatal error in main():", error);
  process.exit(1);
});


# Rust Patterns & Engineering How-Tos

## Speaker Intro

- Principal Firmware Architect in Microsoft SCHIE (Silicon and Cloud Hardware Infrastructure Engineering) team
- Industry veteran with expertise in security, systems programming (firmware, operating systems, hypervisors), CPU and platform architecture, and C++ systems
- Started programming in Rust in 2017 (@AWS EC2), and have been in love with the language ever since

---

A practical guide to intermediate-and-above Rust patterns that arise in real codebases. This is not a language tutorial — it assumes you can write basic Rust and want to level up. Each chapter isolates one concept, explains when and why to use it, and provides compilable examples with inline exercises.

## Who This Is For

- Developers who have finished *The Rust Programming Language* but struggle with "how do I actually design this?"
- C++/C# engineers translating production systems into Rust
- Anyone who has hit a wall with generics, trait bounds, or lifetime errors and wants a systematic toolkit

## Prerequisites

Before starting, you should be comfortable with:
- Ownership, borrowing, and lifetimes (basic level)
- Enums, pattern matching, and `Option`/`Result`
- Structs, methods, and basic traits (`Display`, `Debug`, `Clone`)
- Cargo basics: `cargo build`, `cargo test`, `cargo run`

## How to Use This Book

### Difficulty Legend

Each chapter is tagged with a difficulty level:

| Symbol | Level | Meaning |
|--------|-------|---------|
| 🟢 | Fundamentals | Core concepts every Rust developer needs |
| 🟡 | Intermediate | Patterns used in production codebases |
| 🔴 | Advanced | Deep language mechanics — revisit as needed |

### Pacing Guide

| Chapters | Topic | Suggested Time | Checkpoint |
|----------|-------|----------------|------------|
| **Part I: Type-Level Patterns** | | | |
| 1. Generics 🟢 | Monomorphization, const generics, `const fn` | 1–2 hours | Can explain when `dyn Trait` beats generics |
| 2. Traits 🟡 | Associated types, GATs, blanket impls, vtables | 3–4 hours | Can design a trait with associated types |
| 3. Newtype & Type-State 🟡 | Zero-cost safety, compile-time FSMs | 2–3 hours | Can build a type-state builder pattern |
| 4. PhantomData 🔴 | Lifetime branding, variance, drop check | 2–3 hours | Can explain why `PhantomData<fn(T)>` differs from `PhantomData<T>` |
| **Part II: Concurrency & Runtime** | | | |
| 5. Channels 🟢 | `mpsc`, crossbeam, `select!`, actors | 1–2 hours | Can implement a channel-based worker pool |
| 6. Concurrency 🟡 | Threads, rayon, Mutex, RwLock, atomics | 2–3 hours | Can pick the right sync primitive for a scenario |
| 7. Closures 🟢 | `Fn`/`FnMut`/`FnOnce`, combinators | 1–2 hours | Can write a higher-order function that accepts closures |
| 8. Functional vs. Imperative 🟡 | Combinators, iterator adapters, functional patterns | 2–3 hours | Can explain when functional style beats imperative |
| 9. Smart Pointers 🟡 | Box, Rc, Arc, RefCell, Cow, Pin | 2–3 hours | Can explain when to use each smart pointer |
| **Part III: Systems & Production** | | | |
| 10. Error Handling 🟢 | thiserror, anyhow, `?` operator | 1–2 hours | Can design an error type hierarchy |
| 11. Serialization 🟡 | serde, zero-copy, binary data | 2–3 hours | Can write a custom serde deserializer |
| 12. Unsafe 🔴 | Superpowers, FFI, UB pitfalls, allocators | 2–3 hours | Can wrap unsafe code in a sound safe API |
| 13. Macros 🟡 | `macro_rules!`, proc macros, `syn`/`quote` | 2–3 hours | Can write a declarative macro with `tt` munching |
| 14. Testing 🟢 | Unit/integration/doc tests, proptest, criterion | 1–2 hours | Can set up property-based tests |
| 15. API Design 🟡 | Module layout, ergonomic APIs, feature flags | 2–3 hours | Can apply the "parse, don't validate" pattern |
| 16. Async 🔴 | Futures, Tokio, common pitfalls | 1–2 hours | Can identify async anti-patterns |
| **Appendices** | | | |
| Reference Card | Quick-look trait bounds, lifetimes, patterns | As needed | — |
| Capstone Project | Type-safe task scheduler | 4–6 hours | Submit a working implementation |

**Total estimated time**: 30–45 hours for thorough study with exercises.

### Working Through Exercises

Every chapter ends with a hands-on exercise. For maximum learning:

1. **Try it yourself first** — spend at least 15 minutes before opening the solution
2. **Type the code** — don't copy-paste; typing builds muscle memory
3. **Modify the solution** — add a feature, change a constraint, break something on purpose
4. **Check cross-references** — most exercises combine patterns from multiple chapters

The capstone project (Appendix) ties together patterns from across the book into a single, production-quality system.

## Table of Contents

### Part I: Type-Level Patterns

**[1. Generics — The Full Picture](ch01-generics-the-full-picture.md)** 🟢
Monomorphization, code bloat trade-offs, generics vs enums vs trait objects, const generics, `const fn`.

**[2. Traits In Depth](ch02-traits-in-depth.md)** 🟡
Associated types, GATs, blanket impls, marker traits, vtables, HRTBs, extension traits, enum dispatch.

**[3. The Newtype and Type-State Patterns](ch03-the-newtype-and-type-state-patterns.md)** 🟡
Zero-cost type safety, compile-time state machines, builder patterns, config traits.

**[4. PhantomData — Types That Carry No Data](ch04-phantomdata-types-that-carry-no-data.md)** 🔴
Lifetime branding, unit-of-measure pattern, drop check, variance.

### Part II: Concurrency & Runtime

**[5. Channels and Message Passing](ch05-channels-and-message-passing.md)** 🟢
`std::sync::mpsc`, crossbeam, `select!`, backpressure, actor pattern.

**[6. Concurrency vs Parallelism vs Threads](ch06-concurrency-vs-parallelism-vs-threads.md)** 🟡
OS threads, scoped threads, rayon, Mutex/RwLock/Atomics, Condvar, OnceLock, lock-free patterns.

**[7. Closures and Higher-Order Functions](ch07-closures-and-higher-order-functions.md)** 🟢
`Fn`/`FnMut`/`FnOnce`, closures as parameters/return values, combinators, higher-order APIs.

**[8. Functional vs. Imperative: When Elegance Wins (and When It Doesn't)](ch08-functional-vs-imperative-when-elegance-wins.md)** 🟡
Combinators, iterator adapters, functional patterns.

**[9. Smart Pointers and Interior Mutability](ch09-smart-pointers-and-interior-mutability.md)** 🟡
Box, Rc, Arc, Weak, Cell/RefCell, Cow, Pin, ManuallyDrop.

### Part III: Systems & Production

**[10. Error Handling Patterns](ch10-error-handling-patterns.md)** 🟢
thiserror vs anyhow, `#[from]`, `.context()`, `?` operator, panics.

**[11. Serialization, Zero-Copy, and Binary Data](ch11-serialization-zero-copy-and-binary-data.md)** 🟡
serde fundamentals, enum representations, zero-copy deserialization, `repr(C)`, `bytes::Bytes`.

**[12. Unsafe Rust — Controlled Danger](ch12-unsafe-rust-controlled-danger.md)** 🔴
Five superpowers, sound abstractions, FFI, UB pitfalls, arena/slab allocators.

**[13. Macros — Code That Writes Code](ch13-macros-code-that-writes-code.md)** 🟡
`macro_rules!`, when (not) to use macros, proc macros, derive macros, `syn`/`quote`.

**[14. Testing and Benchmarking Patterns](ch14-testing-and-benchmarking-patterns.md)** 🟢
Unit/integration/doc tests, proptest, criterion, mocking strategies.

**[15. Crate Architecture and API Design](ch15-crate-architecture-and-api-design.md)** 🟡
Module layout, API design checklist, ergonomic parameters, feature flags, workspaces.

**[16. Async/Await Essentials](ch16-asyncawait-essentials.md)** 🔴
Futures, Tokio quick-start, common pitfalls. (For deep async coverage, see our Async Rust Training.)

### Appendices

**[Summary and Reference Card](ch18-summary-and-reference-card.md)**
Pattern decision guide, trait bounds cheat sheet, lifetime elision rules, further reading.

**[Capstone Project: Type-Safe Task Scheduler](ch19-capstone-project.md)**
Integrate generics, traits, typestate, channels, error handling, and testing into a complete system.

***


#!/usr/bin/env node
// caveman-shrink — MCP middleware that proxies an upstream MCP server and
// compresses prose fields so the model sees fewer tokens.
//
// Usage:
//   caveman-shrink <upstream-command> [...args]
//
// Example wrapping the filesystem MCP server:
//   "mcpServers": {
//     "fs-shrunk": {
//       "command": "npx",
//       "args": ["caveman-shrink", "npx", "@modelcontextprotocol/server-filesystem", "/some/path"]
//     }
//   }
//
// Compression is applied to:
//   - "description" fields in tools/list, prompts/list, resources/list responses
//   - same boundaries as caveman-compress: code, URLs, paths, identifiers preserved
//
// What we deliberately DON'T touch in v1:
//   - tools/call response content (high risk of breaking downstream parsing)
//   - request payloads going TO the upstream server
//
// Configuration (env vars):
//   CAVEMAN_SHRINK_FIELDS   comma-separated extra field names to compress
//                           (default: description)
//   CAVEMAN_SHRINK_DEBUG=1  log compression deltas to stderr

const { spawn } = require('child_process');
const { compressDescriptionsInPlace, compress } = require('./compress');

const args = process.argv.slice(2);
if (args.length === 0) {
  process.stderr.write('caveman-shrink: missing upstream command.\n');
  process.stderr.write('Usage: caveman-shrink <upstream-command> [...args]\n');
  process.exit(2);
}

const debug = process.env.CAVEMAN_SHRINK_DEBUG === '1';
const fields = (process.env.CAVEMAN_SHRINK_FIELDS || 'description')
  .split(',').map(s => s.trim()).filter(Boolean);

const { getSpawnOptions } = require('./spawn-options');

const upstream = spawn(args[0], args.slice(1), getSpawnOptions());

upstream.on('error', err => {
  process.stderr.write(`caveman-shrink: failed to spawn upstream: ${err.message}\n`);
  process.exit(1);
});

upstream.on('exit', (code, signal) => {
  if (signal) process.exit(128 + (signal === 'SIGTERM' ? 15 : 9));
  process.exit(code || 0);
});

// JSON-RPC framing over stdio: messages are separated by newlines (the
// MCP stdio transport uses LSP-like content but most servers emit one JSON
// object per line). We line-buffer in both directions and parse opportunistically.
function makeLineBuffer(onLine) {
  let buf = '';
  return chunk => {
    buf += chunk.toString('utf8');
    let nl;
    while ((nl = buf.indexOf('\n')) !== -1) {
      const line = buf.slice(0, nl);
      buf = buf.slice(nl + 1);
      if (line.trim()) onLine(line);
    }
  };
}

function transformResponse(msg) {
  // Compress description fields on list-style responses. Match by method
  // shape — we don't always know the original request's method, so we
  // detect by the presence of a tools/prompts/resources array.
  if (!msg || !msg.result || typeof msg.result !== 'object') return msg;
  const r = msg.result;
  let compressedSomething = false;

  for (const arrayName of ['tools', 'prompts', 'resources', 'resourceTemplates']) {
    if (Array.isArray(r[arrayName])) {
      for (const item of r[arrayName]) {
        for (const field of fields) {
          if (typeof item[field] === 'string') {
            const before = item[field];
            const out = compress(before).compressed;
            if (out !== before) {
              item[field] = out;
              compressedSomething = true;
              if (debug) {
                process.stderr.write(
                  `[caveman-shrink] ${arrayName}.${item.name || '?'}.${field}: ` +
                  `${before.length}→${out.length} bytes\n`
                );
              }
            }
          }
        }
      }
    }
  }

  // Some servers stuff descriptions in nested schemas. Only walk if nothing
  // matched at the top level; avoids double-processing a tool's nested params.
  if (!compressedSomething) compressDescriptionsInPlace(r, fields);

  return msg;
}

// Upstream → us → client (model). Transform here.
upstream.stdout.on('data', makeLineBuffer(line => {
  let msg;
  try { msg = JSON.parse(line); } catch {
    // Pass through unparseable lines unchanged.
    process.stdout.write(line + '\n');
    return;
  }
  const out = transformResponse(msg);
  process.stdout.write(JSON.stringify(out) + '\n');
}));

// Client → us → upstream. Pass through unchanged for v1.
process.stdin.on('data', chunk => upstream.stdin.write(chunk));
process.stdin.on('end',  () => upstream.stdin.end());


//! Integration tests for xAI extractor usage tracking.

use anyhow::Result;
use rig::extractor::ExtractionResponse;
use rig::message::Message;
use rig::prelude::*;
use rig::providers::xai;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use super::support::with_xai_cassette_result;

#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
struct Person {
    name: Option<String>,
    age: Option<u8>,
    profession: Option<String>,
}

#[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
struct Address {
    street: Option<String>,
    city: Option<String>,
    state: Option<String>,
    zip_code: Option<String>,
}

fn assert_compatible_professions(left: Option<&str>, right: &str) -> Result<()> {
    let left = left
        .ok_or_else(|| anyhow::anyhow!("profession should be present"))?
        .trim()
        .to_ascii_lowercase();
    let right = right.trim().to_ascii_lowercase();

    anyhow::ensure!(
        left == right || left.contains(&right) || right.contains(&left),
        "expected compatible professions, got {left:?} and {right:?}"
    );
    Ok(())
}

#[tokio::test]
async fn extract_backward_compatibility() -> Result<()> {
    with_xai_cassette_result(
        "extractor_usage/extract_backward_compatibility",
        |client| async move {
            let extractor = client.extractor::<Person>(xai::GROK_3_MINI).build();

            let person = extractor
                .extract("John Doe is a 30 year old software engineer.")
                .await?;

            anyhow::ensure!(person.name.as_deref() == Some("John Doe"));
            anyhow::ensure!(person.age == Some(30));
            assert_compatible_professions(person.profession.as_deref(), "software engineer")?;

            Ok(())
        },
    )
    .await
}

#[tokio::test]
async fn extract_with_usage_returns_data_and_usage() -> Result<()> {
    with_xai_cassette_result(
        "extractor_usage/extract_with_usage_returns_data_and_usage",
        |client| async move {
            let extractor = client.extractor::<Person>(xai::GROK_3_MINI).build();

            let response: ExtractionResponse<Person> = extractor
                .extract_with_usage("Jane Smith is a 45 year old data scientist.")
                .await?;

            anyhow::ensure!(response.data.name.as_deref() == Some("Jane Smith"));
            anyhow::ensure!(response.data.age == Some(45));
            assert_compatible_professions(response.data.profession.as_deref(), "data scientist")?;
            anyhow::ensure!(response.usage.input_tokens > 0);
            anyhow::ensure!(response.usage.output_tokens > 0);
            anyhow::ensure!(response.usage.total_tokens > 0);

            Ok(())
        },
    )
    .await
}

#[tokio::test]
async fn extract_with_chat_history_with_usage_works() -> Result<()> {
    with_xai_cassette_result(
        "extractor_usage/extract_with_chat_history_with_usage_works",
        |client| async move {
            let extractor = client.extractor::<Address>(xai::GROK_3_MINI).build();

            let chat_history = vec![Message::user(
                "I'm looking at a property that might be interesting.",
            )];

            let response: ExtractionResponse<Address> = extractor
                .extract_with_chat_history_with_usage(
                    "The address is 123 Main St in Springfield, IL 62701.",
                    chat_history,
                )
                .await?;

            anyhow::ensure!(response.data.street.as_deref() == Some("123 Main St"));
            anyhow::ensure!(response.data.city.as_deref() == Some("Springfield"));
            anyhow::ensure!(response.data.state.as_deref() == Some("IL"));
            anyhow::ensure!(response.data.zip_code.as_deref() == Some("62701"));
            anyhow::ensure!(response.usage.input_tokens > 0);
            anyhow::ensure!(response.usage.total_tokens > 0);

            Ok(())
        },
    )
    .await
}

#[tokio::test]
async fn extract_and_extract_with_usage_return_same_data() -> Result<()> {
    with_xai_cassette_result(
        "extractor_usage/extract_and_extract_with_usage_return_same_data",
        |client| async move {
            let extractor = client.extractor::<Person>(xai::GROK_3_MINI).build();

            let text = "Bob Johnson is a 55 year old retired teacher.";
            let person = extractor.extract(text).await?;
            let response = extractor.extract_with_usage(text).await?;

            anyhow::ensure!(person.name.as_deref() == Some("Bob Johnson"));
            anyhow::ensure!(response.data.name.as_deref() == Some("Bob Johnson"));
            anyhow::ensure!(person.age == Some(55));
            anyhow::ensure!(response.data.age == Some(55));
            assert_compatible_professions(person.profession.as_deref(), "retired teacher")?;
            assert_compatible_professions(response.data.profession.as_deref(), "retired teacher")?;
            anyhow::ensure!(response.usage.total_tokens > 0, "usage should be populated");

            Ok(())
        },
    )
    .await
}

#[tokio::test]
async fn usage_tracking_works_for_different_schemas() -> Result<()> {
    with_xai_cassette_result(
        "extractor_usage/usage_tracking_works_for_different_schemas",
        |client| async move {
            let person_extractor = client.extractor::<Person>(xai::GROK_3_MINI).build();
            let person_response = person_extractor
                .extract_with_usage("Alice is a 25 year old developer.")
                .await?;
            anyhow::ensure!(person_response.usage.total_tokens > 0);

            let address_extractor = client.extractor::<Address>(xai::GROK_3_MINI).build();
            let address_response = address_extractor
                .extract_with_usage("456 Oak Avenue, Cambridge, MA 02139")
                .await?;
            anyhow::ensure!(address_response.usage.total_tokens > 0);

            Ok(())
        },
    )
    .await
}


// Hermes Agent native install — fresh install lands skills, uninstall removes them.
//
// Hermes loads skills from <HERMES_HOME>/skills/<category>/<skill>/SKILL.md
// (verified against a live `hermes skills list`). The installer copies the 7
// caveman skill dirs into the `productivity/` category. `--only hermes` makes
// the provider explicit, so no `hermes` binary needs to be on PATH for the
// dispatch to run — we drive it purely through a throwaway HERMES_HOME.
//
// The uninstall test is the important one: PR #524 shipped installHermes with
// NO matching uninstall block, so `--uninstall` silently orphaned all 7 skill
// folders forever. This pins the symmetry so it cannot regress.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, '..', '..');
const INSTALLER = path.join(REPO_ROOT, 'bin', 'install.js');

const SKILLS = ['caveman', 'caveman-commit', 'caveman-review', 'caveman-help', 'caveman-stats', 'caveman-compress', 'cavecrew'];

function freshHome() {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-hermes-'));
}

function runInstaller(args, hermesHome) {
  return spawnSync('node', [INSTALLER, ...args, '--non-interactive', '--no-mcp-shrink'], {
    env: { ...process.env, HERMES_HOME: hermesHome, NO_COLOR: '1' },
    encoding: 'utf8',
  });
}

function productivityDir(hermesHome) {
  return path.join(hermesHome, 'skills', 'productivity');
}

// ── 1. Fresh install drops all 7 skills with SKILL.md in the productivity category ──
test('hermes fresh install lands 7 skill dirs with SKILL.md under skills/productivity/', () => {
  const home = freshHome();
  try {
    const r = runInstaller(['--only', 'hermes'], home);
    assert.notEqual(r.status, 2, `argv error: ${r.stderr}`);

    const prod = productivityDir(home);
    for (const name of SKILLS) {
      assert.ok(fs.existsSync(path.join(prod, name, 'SKILL.md')), `skill ${name}/SKILL.md missing`);
    }
    // caveman-compress ships executable scripts — ensure the recursive copy kept them.
    assert.ok(fs.existsSync(path.join(prod, 'caveman-compress', 'scripts')), 'caveman-compress/scripts/ not copied');
  } finally {
    fs.rmSync(home, { recursive: true, force: true });
  }
});

// ── 2. Uninstall removes every skill we installed (regression guard for #524) ──
test('hermes uninstall removes all installed caveman skills (no orphans)', () => {
  const home = freshHome();
  try {
    const r1 = runInstaller(['--only', 'hermes'], home);
    assert.notEqual(r1.status, 2);
    const prod = productivityDir(home);
    for (const name of SKILLS) {
      assert.ok(fs.existsSync(path.join(prod, name)), `precondition: ${name} should be installed`);
    }

    const r2 = runInstaller(['--uninstall'], home);
    assert.notEqual(r2.status, 2);

    for (const name of SKILLS) {
      assert.equal(fs.existsSync(path.join(prod, name)), false, `${name} survived uninstall (orphaned skill)`);
    }
  } finally {
    fs.rmSync(home, { recursive: true, force: true });
  }
});

// ── 3. Dry-run uninstall must NOT delete anything ──
test('hermes dry-run uninstall leaves skills in place', () => {
  const home = freshHome();
  try {
    runInstaller(['--only', 'hermes'], home);
    const r = runInstaller(['--uninstall', '--dry-run'], home);
    assert.notEqual(r.status, 2);

    const prod = productivityDir(home);
    for (const name of SKILLS) {
      assert.ok(fs.existsSync(path.join(prod, name)), `${name} was deleted by a dry-run uninstall`);
    }
  } finally {
    fs.rmSync(home, { recursive: true, force: true });
  }
});


# Context Store Root Parity Plan

## Status

Planned.

This plan follows the slice spec after the 2026-06-10 product review decisions.
It is written as an implementation plan, but the product contract comes first:
humans and agents should experience a context store as a normal OpenSpec root
with one thin identity file.

## Source Of Truth

Start from `spec.md`.

Also keep these nearby artifacts in view:

- `../../goal.md`
- `../../roadmap.md`
- `../../../AGENTS.md`

The core model for this slice is:

```text
context store = normal OpenSpec root + .openspec-store/store.yaml
```

That means durable planning state lives in normal OpenSpec artifacts:

```text
context-store-root/
  .openspec-store/
    store.yaml
  openspec/
    config.yaml
    specs/
    changes/
      archive/
```

`.openspec-store/store.yaml` is identity metadata only. It is not a planning
model, workspace model, initiative model, migration marker, or compatibility
contract for old beta files.

## User-Facing Frame

What the human wants:

- "Create a context store I can use as a normal OpenSpec place for specs and
  changes."
- "Register the context store my teammate already pushed and I cloned locally."
- "Tell me whether this store is healthy without secretly changing files."
- "Do not overwrite my config, specs, changes, archives, or old local files."

What the agent needs to know:

- Whether the folder is a healthy OpenSpec root.
- Whether the context-store identity metadata exists and matches the store id.
- Whether the local registry already knows this id and path.
- Exactly which files or directories were created by this operation.
- Whether a refusal means "unsafe folder", "not an OpenSpec root", "missing
  confirmation", "metadata problem", or "already registered".

Where the work lives:

- User-authored planning work lives under `openspec/`.
- Portable context-store identity lives in `.openspec-store/store.yaml`.
- Machine-local registration state stays in the local context-store registry.
- Old beta files may exist beside these files, but this slice ignores them.

How the user knows it worked:

- Human output names the store id and root path, then points toward normal
  OpenSpec specs and changes.
- JSON output reports exact resulting state and relative `created_files`.
- Re-running the same command reports "already registered", "already exists",
  or "nothing to change" without mutating files.
- `context-store doctor --json` reports `openspec_root` separately from
  `metadata` and `git`.

## Goal

Make `context-store setup`, `context-store register`, and
`context-store doctor` agree on one product shape:

- Setup creates or preserves a standalone OpenSpec root, then adds thin
  context-store identity metadata.
- Register remembers an existing local root or clone. It does not initialize
  planning files.
- Doctor diagnoses root health, metadata health, and Git health as separate
  concerns.

## Non-Goals

- Do not add store selectors to core lifecycle commands.
- Do not create initiative links, initiative collections, or workspace-owned
  planning state.
- Do not install generated agent skills, slash commands, onboarding files, or
  tool configuration.
- Do not call full `openspec init` from context-store setup or register.
- Do not add clone, pull, push, sync, branch, worktree, dashboard, apply,
  verify, or archive orchestration.
- Do not migrate, clean up, preserve, repair, or back-compat old beta planning
  shapes.
- Do not rewrite public terminology or broad docs in this slice.

## Locked Direction

- A healthy OpenSpec root contains `openspec/`, a config file
  (`openspec/config.yaml` or `openspec/config.yml`), `openspec/specs/`,
  `openspec/changes/`, and `openspec/changes/archive/`.
- When setup creates config, it writes `openspec/config.yaml` with the default
  `spec-driven` schema.
- Setup accepts missing directories, empty directories, Git-only directories,
  and existing healthy OpenSpec roots.
- Setup rejects arbitrary non-empty unmarked folders without writing root or
  metadata files.
- Setup rejects nested Git paths for this slice. Keep that rule isolated so a
  later slice can relax it if the product direction changes.
- Register is for an existing local root or clone. It does not scaffold
  planning files.
- Registering a cloned context store with existing `.openspec-store/store.yaml`
  should succeed and only update local registry state when needed.
- Registering a healthy OpenSpec root without context-store identity should ask
  before turning it into the named context store.
- For non-interactive conversion, use `--yes` on `context-store register` as the
  explicit confirmation for this slice. Without it, JSON/non-interactive mode
  refuses before writing metadata or registry state.
- Old beta files such as `initiatives/`, `.openspec-workspace/`,
  `workspace.yaml`, `AGENTS.md`, `.codex/`, `.claude/`, and `.cursor/` are
  ignored. They are not migrated, deleted, repaired, or treated as proof of a
  healthy root.
- Re-running setup or register for the same healthy id and path is a no-op
  success with no duplicate registry entries and empty `created_files`.
- Doctor reports root health under `openspec_root`, separate from `metadata`
  and `git`, and never repairs while inspecting.

## User Workflows

### Fresh Setup

A human or agent asks OpenSpec to create a new context store in a missing or
empty directory.

Expected result:

- The directory exists.
- `.openspec-store/store.yaml` exists.
- `openspec/config.yaml` exists with `schema: spec-driven`.
- `openspec/specs/`, `openspec/changes/`, and
  `openspec/changes/archive/` exist.
- JSON `created_files` lists the relative paths created by setup.
- No initiative, workspace, agent, slash-command, or tool files are created.

### Git-Only Setup

A human has already run `git init` or cloned an empty repo, so the target folder
contains only `.git/`.

Expected result:

- Setup treats the folder as safe fresh input.
- `.git/` is preserved.
- The normal OpenSpec root and context-store identity are created.
- The command does not stage, commit, push, create remotes, or define Git
  workflow policy.

### Existing Healthy Root Setup

A human already has a standalone OpenSpec root and wants it to become a context
store.

Expected result:

- Existing config, specs, changes, archives, and user-authored content are
  preserved.
- Missing `.openspec-store/store.yaml` is created.
- Existing valid `.openspec-store/store.yaml` is preserved.
- Setup does not overwrite config just because the command ran.

### Teammate Clone Register

A teammate created a context store, pushed it to GitHub, and the human cloned it
locally.

Expected result:

- `context-store register <path>` validates the clone as a healthy OpenSpec
  root with valid context-store identity.
- The local registry remembers that id and path.
- The cloned planning files are not created, rewritten, migrated, or repaired.
- Re-registering the same id and path reports that it is already registered or
  has nothing to change.

### Convert Healthy Root Register

A human has a normal OpenSpec root that does not yet have
`.openspec-store/store.yaml`.

Expected result:

- Interactive register asks whether to turn that root into the named context
  store.
- If confirmed, register writes only the identity metadata and local registry
  entry.
- If declined, register writes nothing.
- JSON/non-interactive register refuses unless explicit confirmation is passed
  with `--yes`.

### Doctor Without Repair

A human or agent wants to know whether registered stores are usable.

Expected result:

- Doctor reports OpenSpec-root health separately from metadata and Git health.
- Missing `openspec/changes/archive/` appears under `openspec_root`.
- Doctor does not create missing directories or repair files.

## Command Behavior

### `context-store setup`

Setup creates or preserves the context-store root for this machine.

Accept:

- Missing target directory.
- Empty target directory.
- Existing target directory that contains only `.git/`.
- Existing healthy OpenSpec root.
- Existing root with matching valid context-store identity.

Reject:

- A file path.
- An arbitrary non-empty unmarked folder.
- A setup target nested inside another Git repository.
- A root with invalid or conflicting `.openspec-store/store.yaml`.

Mutations:

- Create only missing root-shape files and directories.
- Create `.openspec-store/store.yaml` when missing.
- Register the store in the machine-local registry.
- Preserve existing user-authored config, specs, changes, archives, and old
  beta files.

Human output should stay small:

```text
Context store ready

ID: team-context
Location: local-path-redacted
OpenSpec root: ready
Registry: registered

Next: use normal OpenSpec specs and changes in this store.
```

JSON output should report exact state, including relative `created_files`.

### `context-store register`

Register remembers an existing local context store path. It is not an init
command.

Accept:

- An existing healthy OpenSpec root with valid `.openspec-store/store.yaml`.
- An existing healthy OpenSpec root without identity only after clear
  confirmation.

Reject:

- Missing paths.
- Partial OpenSpec roots.
- Arbitrary directories.
- Beta-only directories.
- Invalid or mismatched context-store identity.
- Healthy roots without identity in JSON/non-interactive mode unless `--yes`
  is passed.

Mutations:

- With existing identity, update local registry only when needed.
- With confirmed conversion, create `.openspec-store/store.yaml` and update the
  local registry.
- Never create `openspec/` planning files during register.

Interactive conversion prompt should be direct:

```text
Turn this OpenSpec root into context store "team-context"?
```

### `context-store doctor`

Doctor is the non-mutating health surface.

It checks:

- Registered root path exists and is a directory.
- `.openspec-store/store.yaml` exists, parses, and matches the registry id.
- `openspec/` exists.
- `openspec/config.yaml` or `openspec/config.yml` exists.
- `openspec/specs/` exists.
- `openspec/changes/` exists.
- `openspec/changes/archive/` exists.
- Git health, where existing doctor behavior already reports it.

It does not:

- Create missing OpenSpec directories.
- Create missing config.
- Rewrite metadata.
- Repair registry entries.
- Migrate beta files.

## Agent / JSON Contract

Setup and register mutation output should keep the existing `created_files`
field, but treat it as "relative paths created by this operation." It may list
directories and files.

For a no-op success:

```json
{
  "created_files": [],
  "status": [
    {
      "code": "already_registered",
      "severity": "info",
      "message": "Context store is already registered at this path."
    }
  ]
}
```

For doctor, each store should include a distinct `openspec_root` section beside
`metadata` and `git`:

```json
{
  "id": "team-context",
  "root": "local-path-redacted",
  "openspec_root": {
    "present": true,
    "config": {
      "present": true,
      "path": "openspec/config.yaml"
    },
    "specs": {
      "present": true
    },
    "changes": {
      "present": true
    },
    "archive": {
      "present": false
    },
    "status": [
      {
        "code": "openspec_archive_missing",
        "severity": "error",
        "message": "Missing openspec/changes/archive/."
      }
    ]
  },
  "metadata": {},
  "git": {}
}
```

Exact diagnostic wording can follow existing CLI conventions, but the JSON
shape must let agents distinguish root health from metadata and Git health.

## Implementation Plan

### 1. Add An OpenSpec Root Helper

Create `src/core/openspec-root.ts`.

Responsibilities:

- Define canonical relative paths for a normal OpenSpec root.
- Inspect root health without mutating files.
- Return a healthy/unhealthy result with diagnostics suitable for doctor.
- Ensure the root shape for setup only.
- Create default `openspec/config.yaml` with `schema: spec-driven` when setup
  needs config.
- Preserve existing `config.yaml` or `config.yml`.
- Track a created-path ledger for files and directories.
- Roll back only ledger-created files and empty directories on failure.

This helper should know nothing about context-store registry state, Git policy,
prompts, agents, slash commands, workspaces, or initiatives.

### 2. Share Root Scaffolding With Init Safely

Refactor the directory and config creation pieces from `src/core/init.ts` into
the new helper where useful.

Keep these behaviors separate:

- `openspec init` may keep its current prompts, non-interactive config behavior,
  legacy cleanup, tool detection, and generated assets.
- `context-store setup` uses only root scaffolding and default config creation.
- `context-store register` does not use root scaffolding.

Do not call `InitCommand.execute()` from context-store operations.

### 3. Rework Setup Operations

Update `src/core/context-store/operations.ts` so setup classifies the target
before writing:

- Missing path: create root and full OpenSpec shape.
- Empty path: create full OpenSpec shape.
- Git-only path: preserve `.git/`, create full OpenSpec shape.
- Healthy OpenSpec root: preserve root content, add identity if missing.
- Matching context-store identity: preserve and no-op when everything is
  already healthy.
- Arbitrary non-empty path: refuse without writes.
- Nested Git path: refuse without writes for this slice.

Then perform mutations in a safe order:

1. Ensure the OpenSpec root shape if setup is allowed.
2. Write missing context-store identity metadata.
3. Commit the local registry update.
4. On failure, roll back only paths created in this operation.

Update setup JSON so `created_files` includes both OpenSpec-root paths and
`.openspec-store/store.yaml` when they were created.

### 4. Rework Register Operations

Update register so it begins by inspecting the existing path:

- The path must exist and be a healthy OpenSpec root.
- Existing valid `.openspec-store/store.yaml` supplies or confirms the store id.
- A healthy OpenSpec root without identity can be converted only after user
  confirmation.
- JSON/non-interactive conversion requires `--yes`.
- Missing, partial, arbitrary, beta-only, invalid-metadata, or conflicting roots
  fail before registry mutation.

Register should not create `openspec/`, `config.yaml`, `specs/`, `changes/`, or
`archive/`. It only writes `.openspec-store/store.yaml` for confirmed
conversion, then updates the local registry.

### 5. Make Idempotency Explicit

Update registry and operation behavior so same id plus same root path is a
stable no-op success.

Expected no-op behavior:

- No metadata rewrite.
- No config rewrite.
- No duplicate registry entry.
- `created_files: []`.
- Human output says already registered, already exists, or nothing to change.
- JSON includes an info diagnostic or status entry that agents can interpret.

Same id with a different path and same path under a different id should keep
the existing conflict protections unless the spec for a future replacement flow
changes that.

### 6. Extend Doctor Output

Extend `ContextStoreInspection` in `src/core/context-store/operations.ts` with
OpenSpec-root inspection results.

Update `src/commands/context-store.ts` output types and printers so:

- Human doctor output names OpenSpec-root health separately.
- JSON doctor output includes `openspec_root`.
- Metadata diagnostics remain metadata diagnostics.
- Git diagnostics remain Git diagnostics.
- Doctor never calls the root ensure/scaffold helper.

### 7. Remove Old Initiative-Oriented Guidance

Update setup/register human output and help text in `src/commands/context-store.ts`
so the next step points toward normal OpenSpec specs and changes.

Avoid language like:

- "create an initiative"
- "workspace planning"
- "collections"
- generated agent/tool setup

Use language like:

- "Use normal OpenSpec specs and changes in this store."
- "This store is a standalone OpenSpec root."

### 8. Keep Old Beta Files Ignored

Do not add migration or cleanup logic for old beta files.

If old beta files exist inside an otherwise healthy root, setup/register should
leave them byte-for-byte unchanged.

If old beta files are the only signal in a directory, setup/register should not
treat that directory as healthy or registered. The folder is still arbitrary
non-empty input unless the new root shape is present.

## Test Plan

### Root Helper Tests

Add focused helper coverage, likely in `test/core/openspec-root.test.ts`:

- Healthy root with `config.yaml`.
- Healthy root with `config.yml`.
- Missing config.
- Missing `specs/`.
- Missing `changes/`.
- Missing `changes/archive/`.
- Ensure creates root shape and default config.
- Ensure preserves existing config and user-authored files.
- Rollback removes only ledger-created files and empty directories.

### Command Tests

Update `test/commands/context-store.test.ts`:

- Setup JSON for a missing directory expects the full root shape and
  `created_files`.
- Setup accepts an empty directory.
- Setup accepts a Git-only directory and preserves `.git/`.
- Setup preserves an existing healthy OpenSpec root and config edits.
- Setup creates config in JSON/non-interactive mode without tool selection.
- Setup rejects arbitrary non-empty folders and creates no OpenSpec files.
- Setup rejects nested Git paths, including the old interactive override path.
- Registering a plain folder now fails.
- Registering a cloned healthy context store succeeds without planning-file
  mutation.
- Registering a healthy root without identity prompts for conversion.
- Declining conversion writes nothing.
- JSON/non-interactive conversion without `--yes` refuses.
- JSON/non-interactive conversion with `--yes` writes identity and registry.
- Repeating setup/register produces `created_files: []` and no duplicate
  registry entry.
- Setup/register do not create `initiatives/`, `.openspec-workspace/`,
  `workspace.yaml`, `AGENTS.md`, `.codex/`, `.claude/`, or `.cursor/`.
- Old beta files inside healthy roots are ignored and preserved.
- Beta-only folders are rejected as unsafe or non-root.
- Doctor JSON includes `openspec_root` separate from `metadata` and `git`.
- Doctor reports missing archive under `openspec_root` without creating it.

### Core Context-Store Tests

Add or update operation-level tests around:

- `prepareContextStoreSetup`.
- `setupPreparedContextStore`.
- `registerExistingContextStore`.
- `doctorContextStores`.
- Registry no-op behavior for same id and same path.
- Registry conflict behavior for same id different path and same path different
  id.
- Failure cleanup when registry commit fails after setup/register created files.

### Regression Tests

Keep existing init and workspace tests honest:

- `openspec init` still creates its expected files and generated assets.
- Context-store setup/register do not accidentally inherit those generated
  assets.
- Existing metadata validation tests still enforce the thin identity shape.

## Verification

Run targeted tests first:

```bash
pnpm exec vitest run test/core/openspec-root.test.ts
pnpm exec vitest run test/core/context-store/registry.test.ts
pnpm exec vitest run test/commands/context-store.test.ts
pnpm exec vitest run test/core/init.test.ts
```

Then run the broader repo checks:

```bash
pnpm test
pnpm run build
```

## Main Risks

- Rollback is the easiest place to damage user trust. Use a ledger and remove
  only files/directories created by the current operation.
- Register currently accepts arbitrary folders. Changing that behavior is
  intentional, but tests and user-facing errors need to make the new rule clear.
- Nested Git rejection is locked for this slice but may change later. Keep the
  check small and easy to replace.
- Full `openspec init` is tempting to reuse, but it carries unrelated behavior.
  Use only root scaffolding.
- JSON shape changes should be explicit enough for agents while preserving
  existing fields where practical.

## Done When

- A fresh setup leaves a normal OpenSpec root plus
  `.openspec-store/store.yaml`.
- Setup accepts Git-only directories and existing healthy roots.
- Setup rejects arbitrary non-empty folders and nested Git paths without writes.
- Register succeeds for cloned context stores with existing identity metadata.
- Register can turn a healthy OpenSpec root into a context store only after
  confirmation.
- Register refuses missing, partial, arbitrary, beta-only, or unconfirmed roots
  without writes.
- Doctor reports `openspec_root`, `metadata`, and `git` as separate health
  areas.
- Re-running setup/register is a no-op success for the same healthy id and path.
- User-authored config, specs, changes, archives, identity metadata, and old
  beta files are preserved.
- Setup/register do not create initiative, workspace, agent, slash-command, or
  tool-generation artifacts.
- Targeted tests, `pnpm test`, and `pnpm run build` pass.


import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';

import {
  getGlobalDataDir,
  registerStore,
} from '../../src/core/index.js';
import { writeStoreMetadataState } from '../../src/core/store/foundation.js';
import { runCLI, type RunCLIResult } from '../helpers/run-cli.js';
import { cleanupTempPath } from '../helpers/temp-cleanup.js';

const VALID_DELTA_SPEC = `## ADDED Requirements

### Requirement: Billing SHALL work
The system SHALL create bills.

#### Scenario: Creates bills
- **WHEN** a billing period ends
- **THEN** a bill is created
`;

const INVALID_DELTA_SPEC = `## ADDED Requirements

### Requirement: Billing SHALL work
The system SHALL create bills.
`;

// Targets a spec that does not exist yet: REMOVED deltas are ignored with a
// human-mode warning, which must never leak into JSON stdout.
const REMOVED_ONLY_DELTA_SPEC = `## REMOVED Requirements

### Requirement: Old billing SHALL go away
`;

// MODIFIED deltas against a spec that does not exist make buildUpdatedSpec
// throw during the prepare pass.
const MODIFIED_ONLY_DELTA_SPEC = `## MODIFIED Requirements

### Requirement: Billing SHALL work
The system SHALL create bills differently.

#### Scenario: Creates bills
- **WHEN** a billing period ends
- **THEN** a bill is created
`;

describe('store root selection for normal commands', () => {
  let tempDir: string;
  let appRepo: string;
  let storeRoot: string;
  let globalDataDir: string;
  let env: NodeJS.ProcessEnv;

  beforeEach(async () => {
    tempDir = fs.realpathSync.native(
      fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-store-root-selection-'))
    );
    env = {
      XDG_DATA_HOME: path.join(tempDir, 'data'),
      XDG_CONFIG_HOME: path.join(tempDir, 'config'),
      OPEN_SPEC_INTERACTIVE: '0',
      OPENSPEC_TELEMETRY: '0',
    };
    globalDataDir = getGlobalDataDir({ env });
    appRepo = path.join(tempDir, 'app-repo');
    fs.mkdirSync(appRepo, { recursive: true });
    storeRoot = await registerStoreFixture('team-context');
  });

  afterEach(() => {
    cleanupTempPath(tempDir);
  });

  function createOpenSpecRoot(rootDir: string): void {
    fs.mkdirSync(path.join(rootDir, 'openspec', 'specs'), { recursive: true });
    fs.mkdirSync(path.join(rootDir, 'openspec', 'changes', 'archive'), { recursive: true });
    fs.writeFileSync(path.join(rootDir, 'openspec', 'config.yaml'), 'schema: spec-driven\n');
  }

  async function registerStoreFixture(id: string): Promise<string> {
    const root = path.join(tempDir, 'stores', id);
    createOpenSpecRoot(root);
    await registerStore({ id, localPath: root, globalDataDir });
    return fs.realpathSync.native(root);
  }

  function createChange(
    rootDir: string,
    name: string,
    options: { deltaSpec?: string | null; tasksDone?: boolean } = {}
  ): string {
    const changeDir = path.join(rootDir, 'openspec', 'changes', name);
    fs.mkdirSync(changeDir, { recursive: true });
    fs.writeFileSync(
      path.join(changeDir, 'proposal.md'),
      '## Why\nBilling needs work.\n\n## What Changes\n- **billing:** Add billing\n'
    );
    fs.writeFileSync(
      path.join(changeDir, 'tasks.md'),
      options.tasksDone === false ? '- [ ] Task 1\n' : '- [x] Task 1\n'
    );
    if (options.deltaSpec !== null) {
      const specDir = path.join(changeDir, 'specs', 'billing');
      fs.mkdirSync(specDir, { recursive: true });
      fs.writeFileSync(path.join(specDir, 'spec.md'), options.deltaSpec ?? VALID_DELTA_SPEC);
    }
    return changeDir;
  }

  function parseJson(result: RunCLIResult): any {
    try {
      return JSON.parse(result.stdout);
    } catch (error) {
      throw new Error(
        `Could not parse JSON.\nCommand: ${result.command}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}\n${String(error)}`
      );
    }
  }

  function expectNoLocalOpenSpec(): void {
    expect(fs.existsSync(path.join(appRepo, 'openspec'))).toBe(false);
  }

  describe('selecting a registered store by id', () => {
    it('creates a change only in the store and names the root on stderr', async () => {
      const result = await runCLI(['new', 'change', 'add-billing', '--store', 'team-context'], {
        cwd: appRepo,
        env,
      });
      expect(result.exitCode).toBe(0);
      expect(result.stderr).toContain(`Using OpenSpec root: team-context (${storeRoot})`);
      expect(result.stdout).toContain("Created change 'add-billing'");
      expect(result.stdout).toContain(
        path.join(storeRoot, 'openspec', 'changes', 'add-billing')
      );

      expect(
        fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'add-billing'))
      ).toBe(true);
      expectNoLocalOpenSpec();
    });

    it('includes the shared root block and absolute paths in new change JSON', async () => {
      const result = await runCLI(
        ['new', 'change', 'add-billing', '--store', 'team-context', '--json'],
        { cwd: appRepo, env }
      );
      expect(result.exitCode).toBe(0);

      const json = parseJson(result);
      expect(json.root).toEqual({
        path: storeRoot,
        source: 'store',
        store_id: 'team-context',
      });
      expect(path.isAbsolute(json.change.path)).toBe(true);
      expect(json.change.path).toBe(
        path.join(storeRoot, 'openspec', 'changes', 'add-billing')
      );
      expectNoLocalOpenSpec();
    });

    it('wins over the nearest local root', async () => {
      const localRepo = path.join(tempDir, 'local-repo');
      createOpenSpecRoot(localRepo);
      createChange(localRepo, 'local-change');
      createChange(storeRoot, 'store-change');

      const result = await runCLI(['list', '--json', '--store', 'team-context'], {
        cwd: localRepo,
        env,
      });
      expect(result.exitCode).toBe(0);

      const json = parseJson(result);
      const names = json.changes.map((change: any) => change.name);
      expect(names).toContain('store-change');
      expect(names).not.toContain('local-change');
      expect(json.root.store_id).toBe('team-context');
    });

    it('lists an empty team store before any changes exist', async () => {
      const blankStoreRoot = path.join(tempDir, 'stores', 'blank-context');
      fs.mkdirSync(path.join(blankStoreRoot, 'openspec'), { recursive: true });
      fs.writeFileSync(
        path.join(blankStoreRoot, 'openspec', 'config.yaml'),
        'schema: spec-driven\n'
      );
      await writeStoreMetadataState(blankStoreRoot, {
        version: 1,
        id: 'blank-context',
      });
      const registered = await runCLI(
        ['store', 'register', blankStoreRoot, '--json'],
        { cwd: appRepo, env }
      );
      expect(registered.exitCode).toBe(0);

      const result = await runCLI(['list', '--json', '--store', 'blank-context'], {
        cwd: appRepo,
        env,
      });
      expect(result.exitCode).toBe(0);
      const json = parseJson(result);
      expect(json.changes).toEqual([]);
      expect(json.root).toEqual({
        path: fs.realpathSync.native(blankStoreRoot),
        source: 'store',
        store_id: 'blank-context',
      });
    });

    it('reads, validates, shows, and reports status in the selected store', async () => {
      createChange(storeRoot, 'store-change');

      const status = await runCLI(
        ['status', '--change', 'store-change', '--store', 'team-context', '--json'],
        { cwd: appRepo, env }
      );
      expect(status.exitCode).toBe(0);
      const statusJson = parseJson(status);
      expect(statusJson.changeName).toBe('store-change');
      expect(statusJson.schemaName).toBe('spec-driven');
      expect(statusJson.root).toEqual({
        path: storeRoot,
        source: 'store',
        store_id: 'team-context',
      });

      const instructions = await runCLI(
        ['instructions', 'design', '--change', 'store-change', '--store', 'team-context', '--json'],
        { cwd: appRepo, env }
      );
      expect(instructions.exitCode).toBe(0);
      const instructionsJson = parseJson(instructions);
      expect(instructionsJson.artifactId).toBe('design');
      expect(instructionsJson.root.store_id).toBe('team-context');
      expect(path.isAbsolute(instructionsJson.changeDir)).toBe(true);
      expect(instructionsJson.changeDir).toContain(storeRoot);

      const show = await runCLI(
        ['show', 'store-change', '--store', 'team-context', '--json'],
        { cwd: appRepo, env }
      );
      expect(show.exitCode).toBe(0);
      const showJson = parseJson(show);
      expect(showJson.id).toBe('store-change');
      expect(showJson.root.store_id).toBe('team-context');

      const validate = await runCLI(
        ['validate', 'store-change', '--store', 'team-context', '--json'],
        { cwd: appRepo, env }
      );
      expect(validate.exitCode).toBe(0);
      const validateJson = parseJson(validate);
      expect(validateJson.items[0]).toMatchObject({ id: 'store-change', valid: true });
      expect(validateJson.root.store_id).toBe('team-context');

      expectNoLocalOpenSpec();
    });

    it('lists specs from the store with minimal JSON support', async () => {
      const specDir = path.join(storeRoot, 'openspec', 'specs', 'billing');
      fs.mkdirSync(specDir, { recursive: true });
      fs.writeFileSync(
        path.join(specDir, 'spec.md'),
        '# billing\n\n## Purpose\nBills.\n\n## Requirements\n\n### Requirement: Billing SHALL work\nThe system SHALL bill.\n\n#### Scenario: Bills\n- **WHEN** due\n- **THEN** billed\n'
      );

      const result = await runCLI(['list', '--specs', '--json', '--store', 'team-context'], {
        cwd: appRepo,
        env,
      });
      expect(result.exitCode).toBe(0);
      const json = parseJson(result);
      expect(json.specs).toEqual([{ id: 'billing', requirementCount: 1 }]);
      expect(json.root.store_id).toBe('team-context');
    });

    it('runs bulk validation against the selected store', async () => {
      createChange(storeRoot, 'store-change');

      const result = await runCLI(['validate', '--all', '--store', 'team-context', '--json'], {
        cwd: appRepo,
        env,
      });
      expect(result.exitCode).toBe(0);
      const json = parseJson(result);
      expect(json.items.map((item: any) => item.id)).toContain('store-change');
      expect(json.root.store_id).toBe('team-context');
    });

    it('archives a change into the store archive with JSON output', async () => {
      createChange(storeRoot, 'store-change');

      const result = await runCLI(
        ['archive', 'store-change', '--store', 'team-context', '--json', '--yes'],
        { cwd: appRepo, env }
      );
      expect(result.exitCode).toBe(0);
      expect(result.stdout.trim().startsWith('{')).toBe(true);

      const json = parseJson(result);
      expect(json.archive.change).toBe('store-change');
      expect(json.archive.archivedAs).toMatch(/^\d{4}-\d{2}-\d{2}-store-change$/);
      expect(json.archive.path).toBe(
        path.join(storeRoot, 'openspec', 'changes', 'archive', json.archive.archivedAs)
      );
      expect(json.archive.specsUpdated).toBe(true);
      expect(json.root.store_id).toBe('team-context');

      expect(fs.existsSync(json.archive.path)).toBe(true);
      expect(
        fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'store-change'))
      ).toBe(false);
      expect(
        fs.existsSync(path.join(storeRoot, 'openspec', 'specs', 'billing', 'spec.md'))
      ).toBe(true);
      expectNoLocalOpenSpec();
    });
  });

  describe('human output and stdout purity', () => {
    it('keeps show stdout as the raw markdown payload', async () => {
      createChange(storeRoot, 'store-change');

      const result = await runCLI(['show', 'store-change', '--store', 'team-context'], {
        cwd: appRepo,
        env,
      });
      expect(result.exitCode).toBe(0);
      expect(result.stdout.startsWith('## Why')).toBe(true);
      expect(result.stderr).toContain(`Using OpenSpec root: team-context (${storeRoot})`);
    });

    it('keeps instructions stdout as the artifact payload', async () => {
      createChange(storeRoot, 'store-change');

      const result = await runCLI(
        ['instructions', 'design', '--change', 'store-change', '--store', 'team-context'],
        { cwd: appRepo, env }
      );
      expect(result.exitCode).toBe(0);
      expect(result.stdout.startsWith('<artifact id="design"')).toBe(true);
      expect(result.stderr).toContain('Using OpenSpec root: team-context');
    });

    it('writes the status banner to stderr in human mode', async () => {
      createChange(storeRoot, 'store-change');

      const result = await runCLI(
        ['status', '--change', 'store-change', '--store', 'team-context'],
        { cwd: appRepo, env }
      );
      expect(result.exitCode).toBe(0);
      expect(result.stderr).toContain(`Using OpenSpec root: team-context (${storeRoot})`);
      expect(result.stdout).toContain('Change: store-change');
      expect(result.stdout).not.toContain('Using OpenSpec root');
    });
  });

  describe('selector errors', () => {
    it('rejects --store-path with register guidance', async () => {
      const result = await runCLI(['new', 'change', 'nope', '--store-path', '/x'], {
        cwd: appRepo,
        env,
      });
      expect(result.exitCode).toBe(1);
      const output = result.stdout + result.stderr;
      expect(output).toContain('store register');
      expect(output).toContain('--store <id>');
      expectNoLocalOpenSpec();
      expect(fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'nope'))).toBe(false);
    });

    it('rejects show --store-path despite allowUnknownOption', async () => {
      const result = await runCLI(['show', '--store-path', '/x'], { cwd: appRepo, env });
      expect(result.exitCode).toBe(1);
      const output = result.stdout + result.stderr;
      expect(output).toContain('store register');
    });

    it('reports unknown stores with the same message across commands', async () => {
      const expected =
        "Unknown store 'team-contxt'. Registered stores: team-context.";

      const status = await runCLI(['status', '--store', 'team-contxt'], { cwd: appRepo, env });
      const list = await runCLI(['list', '--store', 'team-contxt'], { cwd: appRepo, env });

      expect(status.exitCode).toBe(1);
      expect(list.exitCode).toBe(1);
      expect(status.stdout + status.stderr).toContain(expected);
      expect(list.stdout + list.stderr).toContain(expected);
    });

    it('rejects an invalid store id format before registry lookup', async () => {
      const result = await runCLI(['list', '--store', 'Bad_Id'], { cwd: appRepo, env });
      expect(result.exitCode).toBe(1);
      expect(result.stdout + result.stderr).toContain('kebab-case');
    });

    it('emits machine-readable resolver failures in JSON mode', async () => {
      const result = await runCLI(['status', '--json', '--store', 'team-contxt'], {
        cwd: appRepo,
        env,
      });
      expect(result.exitCode).toBe(1);
      expect(result.stdout.trim().startsWith('{')).toBe(true);
      const json = parseJson(result);
      expect(json.status[0].code).toBe('unknown_store');
      expect(json.status[0].message).toContain('team-contxt');
    });

    it('reports a corrupt registry as machine-readable JSON, not prose', async () => {
      fs.writeFileSync(
        path.join(globalDataDir, 'stores', 'registry.yaml'),
        '{not yaml: ['
      );

      const result = await runCLI(['status', '--json', '--store', 'team-context'], {
        cwd: appRepo,
        env,
      });
      expect(result.exitCode).toBe(1);
      expect(result.stdout.trim().startsWith('{')).toBe(true);
      const json = parseJson(result);
      expect(json.status[0].severity).toBe('error');
      expect(json.status[0].code).toBe('invalid_store_registry');
    });

    it('fails on an unhealthy store root and points to doctor', async () => {
      const brokenRoot = path.join(tempDir, 'stores', 'broken-context');
      fs.mkdirSync(brokenRoot, { recursive: true });
      await writeStoreMetadataState(brokenRoot, { version: 1, id: 'broken-context' });
      await registerStore({
        id: 'broken-context',
        localPath: brokenRoot,
        globalDataDir,
      });

      const result = await runCLI(['list', '--store', 'broken-context'], {
        cwd: appRepo,
        env,
      });
      expect(result.exitCode).toBe(1);
      expect(result.stdout + result.stderr).toContain('store doctor');
      // No scaffolding or repair happened.
      expect(fs.existsSync(path.join(brokenRoot, 'openspec'))).toBe(false);
    });
  });

  describe('default resolution without --store', () => {
    it('fails with a store hint instead of scaffolding when no root exists', async () => {
      const result = await runCLI(['new', 'change', 'foo'], { cwd: appRepo, env });
      expect(result.exitCode).toBe(1);
      const output = result.stdout + result.stderr;
      expect(output).toContain('team-context');
      expect(output).toContain('--store <id>');
      expect(output).toContain('openspec init');
      expectNoLocalOpenSpec();
    });

    it('treats leftover workspace state as no root at all', async () => {
      fs.mkdirSync(path.join(appRepo, '.openspec-workspace'), { recursive: true });
      fs.writeFileSync(
        path.join(appRepo, '.openspec-workspace', 'view.yaml'),
        'version: 1\nname: platform\ncontext: null\nlinks: {}\n'
      );

      const result = await runCLI(['status'], { cwd: appRepo, env });
      expect(result.exitCode).toBe(1);
      expect(result.stdout + result.stderr).toContain('team-context');
    });

    it('ignores leftover workspace state when a nearby root exists', async () => {
      const localRepo = path.join(tempDir, 'workspace-repo');
      createOpenSpecRoot(localRepo);
      fs.mkdirSync(path.join(localRepo, '.openspec-workspace'), { recursive: true });
      fs.writeFileSync(
        path.join(localRepo, '.openspec-workspace', 'view.yaml'),
        'version: 1\nname: platform\ncontext: null\nlinks: {}\n'
      );
      createChange(localRepo, 'local-change');

      const result = await runCLI(['status', '--change', 'local-change', '--json'], {
        cwd: localRepo,
        env,
      });
      expect(result.exitCode).toBe(0);
      const json = parseJson(result);
      expect(json.schemaName).toBe('spec-driven');
      expect(json.root.source).toBe('nearest');
      expect(json.root.store_id).toBeUndefined();
    });

    it('works inside the standalone repo itself without a flag', async () => {
      createChange(storeRoot, 'store-change');

      const result = await runCLI(['status', '--change', 'store-change', '--json'], {
        cwd: storeRoot,
        env,
      });
      expect(result.exitCode).toBe(0);
      const json = parseJson(result);
      expect(json.changeName).toBe('store-change');
      expect(json.root).toEqual({ path: storeRoot, source: 'nearest' });
    });

    it('keeps implicit-root behavior when no stores are registered', async () => {
      const isolatedEnv = {
        ...env,
        XDG_DATA_HOME: path.join(tempDir, 'data-empty'),
      };

      const result = await runCLI(['status', '--json'], { cwd: appRepo, env: isolatedEnv });
      expect(result.exitCode).toBe(0);
      const json = parseJson(result);
      expect(json.changes).toEqual([]);
      expect(json.root.source).toBe('implicit');
    });
  });

  describe('archive --json is non-interactive', () => {
    it('fails without a change name instead of opening a picker', async () => {
      createChange(storeRoot, 'store-change');

      const result = await runCLI(['archive', '--store', 'team-context', '--json'], {
        cwd: appRepo,
        env,
      });
      expect(result.exitCode).toBe(1);
      expect(result.stdout.trim().startsWith('{')).toBe(true);
      const json = parseJson(result);
      expect(json.archive).toBeNull();
      expect(json.status[0].code).toBe('archive_change_name_required');
    });

    it('reports no active changes for a selected empty store without init guidance', async () => {
      const blankStoreRoot = path.join(tempDir, 'stores', 'archive-blank-context');
      fs.mkdirSync(path.join(blankStoreRoot, 'openspec'), { recursive: true });
      fs.writeFileSync(
        path.join(blankStoreRoot, 'openspec', 'config.yaml'),
        'schema: spec-driven\n'
      );
      await writeStoreMetadataState(blankStoreRoot, {
        version: 1,
        id: 'archive-blank-context',
      });
      const registered = await runCLI(
        ['store', 'register', blankStoreRoot, '--json'],
        { cwd: appRepo, env }
      );
      expect(registered.exitCode).toBe(0);

      const result = await runCLI(
        ['archive', 'missing-change', '--store', 'archive-blank-context', '--json', '--yes'],
        { cwd: appRepo, env }
      );

      expect(result.exitCode).toBe(1);
      const json = parseJson(result);
      expect(json.archive).toBeNull();
      expect(json.status[0]).toEqual(expect.objectContaining({
        code: 'archive_change_not_found',
        message: "Change 'missing-change' not found. No active changes exist in this root.",
      }));
    });

    it('reports validation failures as diagnostics without stdout prose', async () => {
      createChange(storeRoot, 'bad-change', { deltaSpec: INVALID_DELTA_SPEC });

      const result = await runCLI(
        ['archive', 'bad-change', '--store', 'team-context', '--json', '--yes'],
        { cwd: appRepo, env }
      );
      expect(result.exitCode).toBe(1);
      expect(result.stdout.trim().startsWith('{')).toBe(true);
      const json = parseJson(result);
      expect(json.archive).toBeNull();
      expect(json.status[0].code).toBe('archive_validation_failed');
      // The change was not archived.
      expect(
        fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'bad-change'))
      ).toBe(true);
    });

    it('keeps stdout pure when REMOVED deltas target a new spec', async () => {
      createChange(storeRoot, 'removed-change', { deltaSpec: REMOVED_ONLY_DELTA_SPEC });

      const result = await runCLI(
        ['archive', 'removed-change', '--store', 'team-context', '--json', '--yes', '--no-validate'],
        { cwd: appRepo, env }
      );
      expect(result.exitCode).toBe(0);
      // The "REMOVED requirement(s) ignored for new spec" warning must not
      // precede or pollute the JSON payload.
      expect(result.stdout.trim().startsWith('{')).toBe(true);
      const json = parseJson(result);
      expect(json.archive.change).toBe('removed-change');
    });

    it('writes no spec when any rebuilt spec fails validation', async () => {
      // Two delta specs in one change: 'aaa-good' targets a new spec and
      // rebuilds cleanly; 'zzz-bad' targets an existing spec whose current
      // requirement has no scenarios, so its rebuilt content fails the
      // validator only at the late rebuilt-validation pass (the prepare-time
      // structure check does not catch missing scenarios).
      const changeDir = createChange(storeRoot, 'two-spec-change', { deltaSpec: null });
      for (const capability of ['aaa-good', 'zzz-bad']) {
        const specDir = path.join(changeDir, 'specs', capability);
        fs.mkdirSync(specDir, { recursive: true });
        fs.writeFileSync(path.join(specDir, 'spec.md'), VALID_DELTA_SPEC);
      }
      const badTargetDir = path.join(storeRoot, 'openspec', 'specs', 'zzz-bad');
      fs.mkdirSync(badTargetDir, { recursive: true });
      const badTargetContent =
        '# zzz-bad\n\n## Purpose\nLegacy.\n\n## Requirements\n\n### Requirement: Old rule SHALL hold\nThe system SHALL hold.\n';
      fs.writeFileSync(path.join(badTargetDir, 'spec.md'), badTargetContent);

      const result = await runCLI(
        ['archive', 'two-spec-change', '--store', 'team-context', '--json', '--yes'],
        { cwd: appRepo, env }
      );
      expect(result.exitCode).toBe(1);
      const json = parseJson(result);
      expect(json.archive).toBeNull();
      expect(json.status[0].code).toBe('archive_spec_validation_failed');

      // "No files were changed" must be true: the good spec was not created
      // and the bad target is byte-identical.
      expect(
        fs.existsSync(path.join(storeRoot, 'openspec', 'specs', 'aaa-good', 'spec.md'))
      ).toBe(false);
      expect(fs.readFileSync(path.join(badTargetDir, 'spec.md'), 'utf-8')).toBe(
        badTargetContent
      );
      expect(
        fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'two-spec-change'))
      ).toBe(true);
    });

    it('reports spec-update failures as diagnostics without stdout prose', async () => {
      createChange(storeRoot, 'modified-change', { deltaSpec: MODIFIED_ONLY_DELTA_SPEC });

      const result = await runCLI(
        ['archive', 'modified-change', '--store', 'team-context', '--json', '--yes', '--no-validate'],
        { cwd: appRepo, env }
      );
      expect(result.exitCode).toBe(1);
      expect(result.stdout.trim().startsWith('{')).toBe(true);
      const json = parseJson(result);
      expect(json.archive).toBeNull();
      expect(json.status[0].code).toBe('archive_spec_update_failed');
      expect(
        fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'modified-change'))
      ).toBe(true);
    });

    it('refuses incomplete tasks without --yes', async () => {
      createChange(storeRoot, 'wip-change', { tasksDone: false });

      const result = await runCLI(
        ['archive', 'wip-change', '--store', 'team-context', '--json'],
        { cwd: appRepo, env }
      );
      expect(result.exitCode).toBe(1);
      const json = parseJson(result);
      expect(json.status[0].code).toMatch(/archive_tasks_incomplete|archive_confirmation_required/);
      expect(
        fs.existsSync(path.join(storeRoot, 'openspec', 'changes', 'wip-change'))
      ).toBe(true);
    });
  });

  describe('initiative links are retired from normal change flows', () => {
    it('rejects --initiative and creates no files', async () => {
      const localRepo = path.join(tempDir, 'initiative-repo');
      createOpenSpecRoot(localRepo);

      const result = await runCLI(
        ['new', 'change', 'linked-change', '--initiative', 'billing-launch'],
        { cwd: localRepo, env }
      );
      expect(result.exitCode).toBe(1);
      const output = result.stdout + result.stderr;
      expect(output).toContain('--initiative is no longer supported');
      expect(
        fs.existsSync(path.join(localRepo, 'openspec', 'changes', 'linked-change'))
      ).toBe(false);
    });

    it('removes openspec set change entirely', async () => {
      const localRepo = path.join(tempDir, 'set-change-repo');
      createOpenSpecRoot(localRepo);
      createChange(localRepo, 'existing-change');
      const metadataPath = path.join(
        localRepo,
        'openspec',
        'changes',
        'existing-change',
        '.openspec.yaml'
      );

      const result = await runCLI(
        ['set', 'change', 'existing-change', '--initiative', 'billing-launch'],
        { cwd: localRepo, env }
      );
      expect(result.exitCode).not.toBe(0);
      expect(result.stdout + result.stderr).toContain('unknown command');
      expect(fs.existsSync(metadataPath)).toBe(false);

      const help = await runCLI(['--help'], { cwd: localRepo, env });
      expect(help.stdout).not.toContain('Set checked-in OpenSpec metadata');
      expect(help.stdout).not.toMatch(/^\s*set\s/m);
    });
  });

  describe('setup and register point to --store usage', () => {
    it('shows --store usage after setup', async () => {
      const result = await runCLI(
        ['store', 'setup', 'fresh-context', '--path', path.join(tempDir, 'fresh-context'), '--no-init-git'],
        { cwd: appRepo, env }
      );
      expect(result.exitCode).toBe(0);
      expect(result.stdout).toContain('openspec new change <change-id> --store fresh-context');
    });

    it('shows --store usage after register', async () => {
      const registerRoot = path.join(tempDir, 'register-context');
      createOpenSpecRoot(registerRoot);
      await writeStoreMetadataState(registerRoot, {
        version: 1,
        id: 'register-context',
      });

      const result = await runCLI(['store', 'register', registerRoot], {
        cwd: appRepo,
        env,
      });
      expect(result.exitCode).toBe(0);
      expect(result.stdout).toContain('openspec new change <change-id> --store register-context');
    });
  });
});