diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4358205..46bdc2b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,3 +26,7 @@ jobs: - run: pnpm typecheck - run: pnpm test - run: pnpm build + - name: Build and pack the CLI + run: | + pnpm cli:pack + node cli/dist/index.js --version diff --git a/README.md b/README.md index 1df72b9..4da1426 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,7 @@ CodePlans/ │ ├── team/ # Team & org management │ ├── analytics/ # Analytics │ └── settings/ # User settings +├── cli/ # The codeplans command-line tool (npm: @codeplans/cli) ├── components/ │ ├── app-shell.tsx # Sidebar + header layout │ ├── dashboard/ # Dashboard widgets @@ -457,6 +458,18 @@ For any other agent (or Claude Code without the plugin), install the skills with Your agent can then read specs, plans, work items, and tech debt and (with a write-scope key) create, revise, supersede, and link specs, model products, assets, and dependencies, manage plans end-to-end (create, target assets, activate/complete), file work items, manage tasks, and record branch/PR status on plan assets. Keys act as your user, so org access rules and mirrored-field protections apply unchanged. See [`docs/specs/mcp-server-spec.md`](docs/specs/mcp-server-spec.md). +### Command-line tool + +`codeplans` uses the same endpoint and API keys from your terminal: see what's waiting on you, script plans and tasks, and set up products, assets, dependencies and responsibilities from a manifest with `codeplans apply`. + +```bash +npm install -g @codeplans/cli +codeplans login --url https://acme.codeplans.app --key cpk_… +codeplans me +``` + +See the [CLI guide](docs/guides/cli.md). + --- ## Roadmap diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..3be4a7b --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,2 @@ +dist/ +*.tgz diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 0000000..5893b1c --- /dev/null +++ b/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sai Prakash + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..1e01f61 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,26 @@ +# @codeplans/cli + +[CodePlans](https://github.com/SylonZero/CodePlans) from your terminal. Check what's waiting on you, script workspace setups, and drive plans, tasks and work items from shell scripts and CI. + +```bash +npm install -g @codeplans/cli +codeplans login --url https://acme.codeplans.app --key cpk_… # key from Settings → API Keys +codeplans me +``` + +```text +codeplans products # products with counts +codeplans plans --status active # plans with progress +codeplans items --product web --type bug # work items +codeplans search billing retry # search everything +codeplans plan create --product web --title "Retry payouts" --asset "Payments API" +codeplans task add "Add retry queue" --priority high +codeplans apply workspace.json --dry-run # set up products, assets, dependencies, people +codeplans call --arg key=value +``` + +Every command takes `--json`. In CI, set `CODEPLANS_URL` and `CODEPLANS_API_KEY` instead of logging in. Exit codes: `0` success, `1` failed, `2` usage, `3` not connected or key refused. + +Node.js 20 or later; no other dependencies. The full guide, including the `apply` manifest format, is [docs/guides/cli.md](https://github.com/SylonZero/CodePlans/blob/master/docs/guides/cli.md). + +MIT licensed. diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..e0928d7 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,30 @@ +{ + "name": "@codeplans/cli", + "version": "0.1.0", + "description": "Command-line tool for CodePlans: script workspace setups, work with code plans, tasks and work items from your terminal.", + "license": "MIT", + "type": "module", + "bin": { + "codeplans": "dist/index.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "tsc -p tsconfig.json && chmod +x dist/index.js", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/SylonZero/CodePlans.git", + "directory": "cli" + }, + "homepage": "https://github.com/SylonZero/CodePlans/blob/master/docs/guides/cli.md", + "keywords": ["codeplans", "cli", "mcp", "planning", "architecture"] +} diff --git a/cli/src/apply.ts b/cli/src/apply.ts new file mode 100644 index 0000000..fb38f19 --- /dev/null +++ b/cli/src/apply.ts @@ -0,0 +1,208 @@ +// codeplans apply: bring a workspace in line with a setup manifest. Idempotent: +// products are matched by slug, assets by name within their product, +// dependencies by their endpoints and type, and responsibilities by person, +// role and area. Only what's missing is created; nothing is updated or deleted. +import { CliError, EXIT, type Client } from './client.js' + +export type AssetSpec = { + name: string + type: 'app' | 'service' | 'library' | 'datastore' | 'platform' + layer?: string + description?: string + tags?: string[] + repositoryUrl?: string + repoPath?: string + documentationUrl?: string + /** Code owners, by email. Applied only when the asset is created. */ + owners?: string[] +} +export type DependencySpec = { from: string; to: string; type?: 'depends_on' | 'integrates_with' | 'aggregates'; description?: string } +export type PersonSpec = { email: string; responsibility: 'eng_manager' | 'architect' | 'contributor'; area?: string } +export type ProductSpec = { + name: string + slug?: string + description?: string + tags?: string[] + assets?: AssetSpec[] + dependencies?: DependencySpec[] + people?: PersonSpec[] +} +export type Manifest = { products: ProductSpec[] } + +const ASSET_TYPES = ['app', 'service', 'library', 'datastore', 'platform'] +const DEP_TYPES = ['depends_on', 'integrates_with', 'aggregates'] +const RESPONSIBILITIES = ['eng_manager', 'architect', 'contributor'] + +export function slugify(name: string): string { + return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') +} + +/** Checks a parsed manifest's shape, naming the path of the first problem. */ +export function validateManifest(raw: unknown): Manifest { + const fail = (path: string, msg: string): never => { throw new CliError(`Manifest ${path}: ${msg}`, EXIT.usage) } + const isObj = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) + const optStr = (o: Record, k: string, path: string) => { if (o[k] !== undefined && typeof o[k] !== 'string') fail(`${path}.${k}`, 'must be text') } + const optStrs = (o: Record, k: string, path: string) => { + if (o[k] !== undefined && !(Array.isArray(o[k]) && (o[k] as unknown[]).every((x) => typeof x === 'string'))) fail(`${path}.${k}`, 'must be a list of text') + } + const reqStr = (o: Record, k: string, path: string) => { if (typeof o[k] !== 'string' || !(o[k] as string).trim()) fail(`${path}.${k}`, 'is required') } + const arr = (o: Record, k: string, path: string) => { + if (o[k] === undefined) return [] + if (!Array.isArray(o[k])) fail(`${path}.${k}`, 'must be a list') + return o[k] as unknown[] + } + + if (!isObj(raw)) fail('', 'must be a JSON object with a "products" list') + const root = raw as Record + if (!Array.isArray(root.products) || !root.products.length) fail('products', 'must be a non-empty list') + const slugs = new Set() + ;(root.products as unknown[]).forEach((p, i) => { + const path = `products[${i}]` + if (!isObj(p)) fail(path, 'must be an object') + const prod = p as Record + reqStr(prod, 'name', path) + optStr(prod, 'slug', path); optStr(prod, 'description', path); optStrs(prod, 'tags', path) + const slug = (prod.slug as string | undefined) ?? slugify(prod.name as string) + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) fail(`${path}.slug`, `"${slug}" must be lowercase letters, digits and single hyphens`) + if (slugs.has(slug)) fail(`${path}.slug`, `"${slug}" appears twice`) + slugs.add(slug) + const names = new Set() + arr(prod, 'assets', path).forEach((a, j) => { + const ap = `${path}.assets[${j}]` + if (!isObj(a)) fail(ap, 'must be an object') + const asset = a as Record + reqStr(asset, 'name', ap) + if (!ASSET_TYPES.includes(asset.type as string)) fail(`${ap}.type`, `must be one of ${ASSET_TYPES.join(', ')}`) + for (const k of ['layer', 'description', 'repositoryUrl', 'repoPath', 'documentationUrl']) optStr(asset, k, ap) + optStrs(asset, 'tags', ap); optStrs(asset, 'owners', ap) + const key = (asset.name as string).toLowerCase() + if (names.has(key)) fail(`${ap}.name`, `"${asset.name}" appears twice in this product`) + names.add(key) + }) + arr(prod, 'dependencies', path).forEach((d, j) => { + const dp = `${path}.dependencies[${j}]` + if (!isObj(d)) fail(dp, 'must be an object') + const dep = d as Record + reqStr(dep, 'from', dp); reqStr(dep, 'to', dp); optStr(dep, 'description', dp) + if (dep.type !== undefined && !DEP_TYPES.includes(dep.type as string)) fail(`${dp}.type`, `must be one of ${DEP_TYPES.join(', ')}`) + }) + arr(prod, 'people', path).forEach((x, j) => { + const pp = `${path}.people[${j}]` + if (!isObj(x)) fail(pp, 'must be an object') + const person = x as Record + reqStr(person, 'email', pp); optStr(person, 'area', pp) + if (!RESPONSIBILITIES.includes(person.responsibility as string)) fail(`${pp}.responsibility`, `must be one of ${RESPONSIBILITIES.join(', ')}`) + }) + }) + return raw as Manifest +} + +export type ApplyResult = { + kind: 'product' | 'asset' | 'dependency' | 'person' + product: string + name: string + status: 'created' | 'exists' | 'would create' | 'failed' | 'skipped' + error?: string +} + +type ProductList = { id: string; slug: string }[] +type ProductDetail = { id: string; slug: string; assets: { id: string; name: string }[]; dependencies: { sourceAssetId: string; targetAssetId: string; dependencyType: string }[] } +type People = { members: { email: string; responsibility: string; area: string | null }[] } + +/** Applies the manifest, reporting each step as it happens. */ +export async function applyManifest( + client: Client, + manifest: Manifest, + opts: { dryRun?: boolean; onResult?: (r: ApplyResult) => void } = {}, +): Promise { + const results: ApplyResult[] = [] + const report = (r: ApplyResult) => { results.push(r); opts.onResult?.(r) } + const errorOf = (err: unknown) => (err instanceof Error ? err.message : String(err)) + const create = opts.dryRun ? 'would create' : 'created' + + const existing = await client.call('list_products') + for (const spec of manifest.products) { + const slug = spec.slug ?? slugify(spec.name) + const label = slug + let detail: ProductDetail | null = null + let people: People | null = null + + const found = existing.find((p) => p.slug === slug) + if (found) { + detail = await client.call('get_product', { slug }) + if (spec.people?.length) people = await client.call('list_product_people', { productId: detail.id }) + report({ kind: 'product', product: label, name: spec.name, status: 'exists' }) + } else if (opts.dryRun) { + report({ kind: 'product', product: label, name: spec.name, status: 'would create' }) + } else { + try { + await client.call('create_product', { name: spec.name, slug, description: spec.description ?? '', tags: spec.tags ?? [] }) + detail = await client.call('get_product', { slug }) + people = { members: [] } + report({ kind: 'product', product: label, name: spec.name, status: 'created' }) + } catch (err) { + report({ kind: 'product', product: label, name: spec.name, status: 'failed', error: errorOf(err) }) + for (const a of spec.assets ?? []) report({ kind: 'asset', product: label, name: a.name, status: 'skipped' }) + for (const d of spec.dependencies ?? []) report({ kind: 'dependency', product: label, name: `${d.from} → ${d.to}`, status: 'skipped' }) + for (const p of spec.people ?? []) report({ kind: 'person', product: label, name: `${p.email} (${p.responsibility})`, status: 'skipped' }) + continue + } + } + + // Assets by name, case-insensitive. New ones in a dry run have no id yet. + const assetIds = new Map((detail?.assets ?? []).map((a) => [a.name.toLowerCase(), a.id])) + for (const a of spec.assets ?? []) { + const key = a.name.toLowerCase() + if (assetIds.has(key)) { report({ kind: 'asset', product: label, name: a.name, status: 'exists' }); continue } + if (opts.dryRun || !detail) { assetIds.set(key, null); report({ kind: 'asset', product: label, name: a.name, status: 'would create' }); continue } + try { + const created = await client.call<{ id: string }>('create_asset', { + productId: detail.id, name: a.name, type: a.type, description: a.description ?? '', tags: a.tags ?? [], + ...(a.layer ? { layer: a.layer } : {}), ...(a.repositoryUrl ? { repositoryUrl: a.repositoryUrl } : {}), + ...(a.repoPath ? { repoPath: a.repoPath } : {}), ...(a.documentationUrl ? { documentationUrl: a.documentationUrl } : {}), + ...(a.owners?.length ? { ownerEmails: a.owners } : {}), + }) + assetIds.set(key, created.id) + report({ kind: 'asset', product: label, name: a.name, status: create }) + } catch (err) { + report({ kind: 'asset', product: label, name: a.name, status: 'failed', error: errorOf(err) }) + } + } + + for (const d of spec.dependencies ?? []) { + const name = `${d.from} → ${d.to}` + const type = d.type ?? 'depends_on' + const from = assetIds.get(d.from.toLowerCase()) + const to = assetIds.get(d.to.toLowerCase()) + if (from === undefined || to === undefined) { + report({ kind: 'dependency', product: label, name, status: 'failed', error: `No asset named "${from === undefined ? d.from : d.to}" in ${label}` }) + continue + } + if (from && to && detail?.dependencies.some((e) => e.sourceAssetId === from && e.targetAssetId === to && e.dependencyType === type)) { + report({ kind: 'dependency', product: label, name, status: 'exists' }) + continue + } + if (opts.dryRun || !from || !to) { report({ kind: 'dependency', product: label, name, status: 'would create' }); continue } + try { + await client.call('add_asset_dependency', { sourceAssetId: from, targetAssetId: to, dependencyType: type, ...(d.description ? { description: d.description } : {}) }) + report({ kind: 'dependency', product: label, name, status: create }) + } catch (err) { + report({ kind: 'dependency', product: label, name, status: 'failed', error: errorOf(err) }) + } + } + + for (const p of spec.people ?? []) { + const name = `${p.email} (${p.responsibility}${p.area ? `, ${p.area}` : ''})` + const has = people?.members.some((m) => m.email.toLowerCase() === p.email.toLowerCase() && m.responsibility === p.responsibility && (m.area ?? null) === (p.area ?? null)) + if (has) { report({ kind: 'person', product: label, name, status: 'exists' }); continue } + if (opts.dryRun || !detail) { report({ kind: 'person', product: label, name, status: 'would create' }); continue } + try { + await client.call('assign_responsibility', { productId: detail.id, email: p.email, responsibility: p.responsibility, ...(p.area ? { area: p.area } : {}) }) + report({ kind: 'person', product: label, name, status: create }) + } catch (err) { + report({ kind: 'person', product: label, name, status: 'failed', error: errorOf(err) }) + } + } + } + return results +} diff --git a/cli/src/cli.ts b/cli/src/cli.ts new file mode 100644 index 0000000..d6c4527 --- /dev/null +++ b/cli/src/cli.ts @@ -0,0 +1,171 @@ +// Command registry and runner. Every command declares its options for +// node:util parseArgs; the runner adds --json, --profile and --help, turns +// errors into messages on stderr and exit codes, and creates the client lazily +// (login and help never need one). +import { parseArgs, type ParseArgsConfig } from 'node:util' +import { Client, CliError, EXIT, type FetchLike } from './client.js' +import { configPath, resolveConnection, type Connection, type Env } from './config.js' +import { createOut, paint, table, type Out, type Sink } from './output.js' + +export const VERSION = '0.1.0' + +type Options = NonNullable +export type Values = Record + +export type Context = { + out: Out + env: Env + configFile: string + profile?: string + fetch: FetchLike + /** The connection for this run (env or profile); throws when not logged in. */ + connection: () => Connection + client: () => Client +} + +export type Command = { + name: string + aliases?: string[] + summary: string + usage: string + /** Extra help shown under the usage line. */ + details?: string + options?: Options + /** At least this many positional arguments. */ + minArgs?: number + run: (ctx: Context, args: string[], values: Values) => Promise +} + +const GLOBAL_OPTIONS: Options = { + json: { type: 'boolean' }, + profile: { type: 'string' }, + help: { type: 'boolean', short: 'h' }, +} + +export type RunDeps = { + env?: Env + stdout?: Sink + stderr?: Sink + fetch?: FetchLike +} + +/** Runs the CLI and returns the exit code. */ +export async function run(argv: string[], commands: Command[], deps: RunDeps = {}): Promise { + const env = deps.env ?? process.env + const wantsJson = argv.includes('--json') + const out = createOut(wantsJson, deps.stdout ?? process.stdout, deps.stderr ?? process.stderr, env) + + // The command comes first: one or two words ("plan", "plan create"); the longest match wins. + const lead: string[] = [] + for (const a of argv) { if (a.startsWith('-')) break; lead.push(a) } + const find = (name: string) => commands.find((c) => c.name === name || c.aliases?.includes(name)) + const two = lead.length >= 2 ? find(`${lead[0]} ${lead[1]}`) : undefined + const command = two ?? (lead.length ? find(lead[0]) : undefined) + + if (!command) { + if (argv.includes('--version') || argv.includes('-v')) { out.print(VERSION); return 0 } + if (!lead.length || lead[0] === 'help') { + const topic = lead.slice(1) + printHelp(out, commands, topic.length ? find(topic.join(' ')) ?? find(topic[0]) : undefined) + return 0 + } + out.error(`Unknown command "${lead[0]}". Run codeplans help for the list.`) + return EXIT.usage + } + + const rest = argv.slice(two ? 2 : 1) + + let positionals: string[] + let values: Values + try { + const parsed = parseArgs({ args: rest, options: { ...GLOBAL_OPTIONS, ...command.options }, allowPositionals: true, strict: true }) + positionals = parsed.positionals + values = parsed.values as Values + } catch (err) { + out.error(`${err instanceof Error ? err.message : String(err)}\nUsage: ${command.usage}`) + return EXIT.usage + } + if (values.help) { printHelp(out, commands, command); return 0 } + if (positionals.length < (command.minArgs ?? 0)) { + out.error(`Usage: ${command.usage}`) + return EXIT.usage + } + + let cachedClient: Client | undefined + const ctx: Context = { + out, + env, + configFile: configPath(env), + profile: values.profile as string | undefined, + fetch: deps.fetch ?? fetch, + connection: () => resolveConnection({ profile: values.profile as string | undefined }, env, configPath(env)), + client: () => { + if (!cachedClient) { + const conn = ctx.connection() + cachedClient = new Client(conn.url, conn.key, ctx.fetch) + } + return cachedClient + }, + } + + try { + await command.run(ctx, positionals, values) + return 0 + } catch (err) { + if (err instanceof CliError) { + out.error(paint({ color: !!(deps.stderr ?? process.stderr).isTTY && !env.NO_COLOR }, 'red', err.message)) + return err.exitCode + } + out.error(`Unexpected error: ${err instanceof Error ? err.stack ?? err.message : String(err)}`) + return EXIT.failed + } +} + +function printHelp(out: Out, commands: Command[], command?: Command) { + if (command) { + out.print(`Usage: ${command.usage}`) + out.print() + out.print(command.summary) + if (command.details) { out.print(); out.print(command.details) } + out.print() + out.print('Global options: --json (machine-readable output), --profile , --help') + return + } + out.print(`codeplans ${VERSION}: CodePlans from your terminal`) + out.print() + out.print('Usage: codeplans [options]') + out.print() + for (const line of table(commands.map((c) => [` ${c.name}`, c.summary]))) out.print(line) + out.print() + out.print('Global options: --json --profile --help --version') + out.print('Connect with: codeplans login --url https://your-codeplans --key cpk_…') + out.print('Docs: https://github.com/SylonZero/CodePlans/blob/master/docs/guides/cli.md') +} + +// ── Helpers shared by commands ────────────────────────────────────────────── + +export type ProductRow = { id: string; name: string; slug: string; assetCount?: number; activePlanCount?: number; planCount?: number; workItemCount?: number; specCount?: number } + +/** A product by id, slug or (case-insensitive) name. */ +export async function resolveProduct(client: Client, ref: string): Promise { + const products = await client.call('list_products') + const lower = ref.toLowerCase() + const match = products.find((p) => p.id === ref || p.slug === lower) + ?? products.filter((p) => p.name.toLowerCase() === lower).at(0) + if (!match) throw new CliError(`No product "${ref}". Products: ${products.map((p) => p.slug).join(', ') || 'none'}.`) + return match +} + +export function str(v: Values[string]): string | undefined { + return typeof v === 'string' ? v : undefined +} + +export function list(v: Values[string]): string[] { + return Array.isArray(v) ? v : typeof v === 'string' ? [v] : [] +} + +export function oneOf(value: string | undefined, allowed: readonly T[], flag: string): T | undefined { + if (value === undefined) return undefined + if (!(allowed as readonly string[]).includes(value)) throw new CliError(`--${flag} must be one of: ${allowed.join(', ')}`, EXIT.usage) + return value as T +} diff --git a/cli/src/client.ts b/cli/src/client.ts new file mode 100644 index 0000000..9d7264a --- /dev/null +++ b/cli/src/client.ts @@ -0,0 +1,107 @@ +// Talks to a CodePlans server through its authenticated MCP endpoint +// (/api/mcp/mcp). The endpoint is stateless, so each call is one JSON-RPC POST; +// the response is either JSON or a server-sent event stream. + +/** Exit codes: 1 a failed operation, 2 a usage error, 3 authentication or connection. */ +export const EXIT = { failed: 1, usage: 2, auth: 3 } as const + +export class CliError extends Error { + constructor(message: string, readonly exitCode: number = EXIT.failed) { + super(message) + } +} + +export type ToolInfo = { name: string; description?: string; inputSchema?: { properties?: Record; required?: string[] } } + +type RpcResponse = { + id?: number | string | null + result?: unknown + error?: { code: number; message: string } +} + +type ToolResult = { content?: { type: string; text?: string }[]; isError?: boolean } + +const PROTOCOL_VERSION = '2025-06-18' + +export type FetchLike = (input: string, init: RequestInit) => Promise + +export class Client { + private nextId = 1 + readonly endpoint: string + + constructor(readonly baseUrl: string, private readonly key: string, private readonly fetchImpl: FetchLike = fetch) { + this.endpoint = `${baseUrl.replace(/\/+$/, '')}/api/mcp/mcp` + } + + /** Calls a tool and returns its JSON result. A result carrying an `error` string is thrown as a CliError. */ + async call(name: string, args: Record = {}): Promise { + const result = (await this.rpc('tools/call', { name, arguments: args })) as ToolResult + const text = (result.content ?? []).filter((c) => c.type === 'text').map((c) => c.text ?? '').join('\n') + if (result.isError) throw new CliError(text || `${name} failed`) + let data: unknown = text + try { data = text ? JSON.parse(text) : null } catch { /* plain text result */ } + if (data && typeof data === 'object' && !Array.isArray(data) && typeof (data as { error?: unknown }).error === 'string') { + throw new CliError((data as { error: string }).error) + } + return data as T + } + + async listTools(): Promise { + const result = (await this.rpc('tools/list', {})) as { tools?: ToolInfo[] } + return result.tools ?? [] + } + + private async rpc(method: string, params: Record): Promise { + const id = this.nextId++ + let res: Response + try { + res = await this.fetchImpl(this.endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + authorization: `Bearer ${this.key}`, + 'mcp-protocol-version': PROTOCOL_VERSION, + }, + body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), + }) + } catch (err) { + const cause = err instanceof Error && err.cause instanceof Error ? `: ${err.cause.message}` : '' + throw new CliError(`Could not reach ${this.baseUrl}${cause}`, EXIT.auth) + } + if (res.status === 401 || res.status === 403) { + throw new CliError(`The server at ${this.baseUrl} refused the API key. Check it in Settings → API Keys, or run codeplans login again.`, EXIT.auth) + } + if (res.status === 404) throw new CliError(`No CodePlans MCP endpoint at ${this.endpoint}. Is the URL right?`, EXIT.auth) + const body = await res.text() + if (!res.ok) throw new CliError(`The server answered ${res.status}${body ? `: ${body.slice(0, 200)}` : ''}`, res.status >= 500 ? EXIT.failed : EXIT.auth) + const message = parseResponse(body, res.headers.get('content-type') ?? '', id) + if (message.error) { + // A thrown tool error (access, a review-workflow block, validation) arrives as a JSON-RPC error. + throw new CliError(message.error.message) + } + return message.result + } +} + +/** Reads the JSON-RPC response with the given id from a JSON body or a server-sent event stream. */ +export function parseResponse(body: string, contentType: string, id: number): RpcResponse { + const candidates: RpcResponse[] = [] + if (contentType.includes('text/event-stream')) { + for (const event of body.split(/\r?\n\r?\n/)) { + const data = event.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trimStart()).join('\n') + if (!data) continue + try { candidates.push(JSON.parse(data)) } catch { /* not JSON: skip */ } + } + } else { + try { + const parsed = JSON.parse(body) + candidates.push(...(Array.isArray(parsed) ? parsed : [parsed])) + } catch { + throw new CliError(`Unexpected response from the server: ${body.slice(0, 200)}`) + } + } + const match = candidates.find((m) => m.id === id) ?? candidates.find((m) => 'result' in m || 'error' in m) + if (!match) throw new CliError('The server sent no response to the request.') + return match +} diff --git a/cli/src/commands.ts b/cli/src/commands.ts new file mode 100644 index 0000000..a68d5a5 --- /dev/null +++ b/cli/src/commands.ts @@ -0,0 +1,15 @@ +import type { Command } from './cli.js' +import { login, logout, status } from './commands/auth.js' +import { me, products, product, plans, plan, items, search, tools } from './commands/read.js' +import { planCreate, planActivate, planComplete, taskAdd, taskStart, taskDone, taskReset, itemAdd, itemStatus, call } from './commands/write.js' +import { apply } from './commands/apply.js' + +/** Every command, in the order help lists them. */ +export const COMMANDS: Command[] = [ + login, logout, status, + me, products, product, + plans, plan, planCreate, planActivate, planComplete, + taskAdd, taskStart, taskDone, taskReset, + items, itemAdd, itemStatus, + search, apply, tools, call, +] diff --git a/cli/src/commands/apply.ts b/cli/src/commands/apply.ts new file mode 100644 index 0000000..01c1cd9 --- /dev/null +++ b/cli/src/commands/apply.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs' +import { CliError, EXIT } from '../client.js' +import { applyManifest, validateManifest, type ApplyResult } from '../apply.js' +import { paint, printJson } from '../output.js' +import type { Command } from '../cli.js' + +const MARK: Record = { created: '+', 'would create': '+', exists: '=', failed: '!', skipped: '-' } + +export const apply: Command = { + name: 'apply', + summary: 'Create the products, assets, dependencies and responsibilities a setup manifest describes', + usage: 'codeplans apply [--dry-run]', + details: [ + 'Re-running is safe: products match by slug, assets by name, dependencies by their endpoints,', + 'and responsibilities by person and role. Only what is missing is created; nothing is changed or deleted.', + 'Use --dry-run to see what would be created. See docs/guides/cli.md for the manifest format.', + ].join('\n'), + options: { 'dry-run': { type: 'boolean' } }, + minArgs: 1, + async run(ctx, [file], values) { + let raw: unknown + try { + raw = JSON.parse(readFileSync(file === '-' ? 0 : file, 'utf8')) + } catch (err) { + throw new CliError(`Can't read ${file === '-' ? 'stdin' : file} as JSON: ${err instanceof Error ? err.message : err}`, EXIT.usage) + } + const manifest = validateManifest(raw) + const dryRun = !!values['dry-run'] + const { out } = ctx + const style = (s: ApplyResult['status']) => (s === 'failed' ? 'red' : s === 'exists' || s === 'skipped' ? 'dim' : 'green') as 'red' | 'dim' | 'green' + const results = await applyManifest(ctx.client(), manifest, { + dryRun, + onResult: out.json ? undefined : (r) => out.print(paint(out, style(r.status), `${MARK[r.status]} ${r.kind.padEnd(10)} ${r.product === r.name ? r.name : `${r.product}: ${r.name}`} ${r.status}${r.error ? ` (${r.error})` : ''}`)), + }) + const count = (s: ApplyResult['status']) => results.filter((r) => r.status === s).length + const failed = count('failed') + if (out.json) printJson(out, { dryRun, results }) + else { + out.print() + out.print(dryRun + ? `Dry run: ${count('would create')} to create, ${count('exists')} already there.` + : `${count('created')} created, ${count('exists')} already there${failed ? `, ${failed} failed` : ''}${count('skipped') ? `, ${count('skipped')} skipped` : ''}.`) + } + if (failed) throw new CliError(`${failed} step${failed === 1 ? '' : 's'} failed.`) + }, +} diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts new file mode 100644 index 0000000..2492834 --- /dev/null +++ b/cli/src/commands/auth.ts @@ -0,0 +1,76 @@ +import { Client, CliError, EXIT } from '../client.js' +import { maskKey, normalizeUrl, readConfig, writeConfig } from '../config.js' +import { paint, printJson, table } from '../output.js' +import { str, type Command, type ProductRow } from '../cli.js' + +export const login: Command = { + name: 'login', + summary: 'Save a connection (URL and API key) as a profile, after checking it works', + usage: 'codeplans login --url --key [--profile ]', + details: [ + 'Create an API key in CodePlans under Settings → API Keys. Read keys can list and read;', + 'read/write keys can also change things. The profile is saved to ~/.config/codeplans/config.json', + '(mode 0600) and becomes the current one. In CI, set CODEPLANS_URL and CODEPLANS_API_KEY instead.', + ].join('\n'), + options: { url: { type: 'string' }, key: { type: 'string' } }, + async run(ctx, _args, values) { + const rawUrl = str(values.url) ?? ctx.env.CODEPLANS_URL + const key = str(values.key) ?? ctx.env.CODEPLANS_API_KEY + if (!rawUrl || !key) throw new CliError('Usage: codeplans login --url --key ', EXIT.usage) + if (!key.startsWith('cpk_')) throw new CliError('That doesn\'t look like a CodePlans API key (they start with cpk_).', EXIT.usage) + const url = normalizeUrl(rawUrl) + const products = await new Client(url, key, ctx.fetch).call('list_products') + const name = ctx.profile ?? 'default' + const config = readConfig(ctx.configFile) + config.profiles[name] = { url, key } + config.current = name + writeConfig(config, ctx.configFile) + if (ctx.out.json) return printJson(ctx.out, { profile: name, url, products: products.length }) + ctx.out.print(`${paint(ctx.out, 'green', '✓')} Connected to ${url} as profile "${name}" (${products.length} product${products.length === 1 ? '' : 's'} visible).`) + }, +} + +export const logout: Command = { + name: 'logout', + summary: 'Remove a saved profile (the current one unless --profile is given)', + usage: 'codeplans logout [--profile ]', + async run(ctx) { + const config = readConfig(ctx.configFile) + const name = ctx.profile ?? config.current ?? 'default' + if (!config.profiles[name]) throw new CliError(`No profile named "${name}".`, EXIT.usage) + delete config.profiles[name] + if (config.current === name) config.current = Object.keys(config.profiles)[0] + writeConfig(config, ctx.configFile) + if (ctx.out.json) return printJson(ctx.out, { removed: name, current: config.current ?? null }) + ctx.out.print(`Removed profile "${name}".${config.current ? ` Current profile: "${config.current}".` : ''}`) + }, +} + +export const status: Command = { + name: 'status', + summary: 'Show the connection in use and check that it works', + usage: 'codeplans status', + async run(ctx) { + const conn = ctx.connection() + const config = readConfig(ctx.configFile) + let products: ProductRow[] | null = null + let problem: string | null = null + try { + products = await ctx.client().call('list_products') + } catch (err) { + problem = err instanceof Error ? err.message : String(err) + } + if (ctx.out.json) { + printJson(ctx.out, { url: conn.url, profile: conn.profile, source: conn.source, key: maskKey(conn.key), ok: !problem, products: products?.length ?? null, error: problem, profiles: Object.keys(config.profiles) }) + } else { + for (const line of table([ + ['URL', conn.url], + ['Profile', conn.source === 'env' ? 'environment (CODEPLANS_URL, CODEPLANS_API_KEY)' : conn.profile ?? ''], + ['API key', maskKey(conn.key)], + ['Status', problem ? paint(ctx.out, 'red', problem) : paint(ctx.out, 'green', `connected, ${products!.length} product${products!.length === 1 ? '' : 's'} visible`)], + ...(Object.keys(config.profiles).length > 1 ? [['Profiles', Object.keys(config.profiles).join(', ')]] : []), + ])) ctx.out.print(line) + } + if (problem) throw new CliError('Not connected.', EXIT.auth) + }, +} diff --git a/cli/src/commands/read.ts b/cli/src/commands/read.ts new file mode 100644 index 0000000..b666b39 --- /dev/null +++ b/cli/src/commands/read.ts @@ -0,0 +1,238 @@ +import { CliError, EXIT } from '../client.js' +import { ago, paint, printJson, table, truncate } from '../output.js' +import { list, oneOf, resolveProduct, str, type Command, type ProductRow } from '../cli.js' + +// Shapes of the MCP results the commands print (only the fields used). +type InboxItem = { verb: string; title: string; detail?: string; url: string; reason: string; productName?: string; urgent?: boolean } +type MyWork = { + needsYou: InboxItem[] + inFlight: { + taskGroups: { planId: string; planTitle: string; productName: string; tasks: { id: string; title: string; status: string; overdue: boolean }[] }[] + plans: { id: string; title: string; status: string; progress: number; atRisk: boolean }[] + specs: { id: string; title: string; status: string; version: number }[] + } + watching: InboxItem[] +} +type Asset = { id: string; name: string; type: string; layer?: string | null; health: string; openDebtCount?: number; owners?: { name: string }[] } +type Product = ProductRow & { description?: string; assets: Asset[]; dependencies: { sourceAssetName: string; targetAssetName: string; dependencyType: string }[] } +type Plan = { id: string; title: string; status: string; type: string; productName?: string; taskCount?: number; completedTaskCount?: number; revision?: number; updatedAt?: string } +type Task = { id: string; title: string; status: string; priority: string; assigneeId?: string | null } +type PlanDetail = Plan & { + description?: string + tasks?: Task[] + assignees?: { id: string; name: string }[] + planAssets?: { assetName: string; branch?: string | null; prUrl?: string | null; prStatus?: string | null }[] + linkedWorkItems?: { id: string; title: string; type: string; status: string }[] +} +type WorkItem = { id: string; title: string; type: string; severity: string; status: string; asset?: string | null; area?: string | null; product?: string; owner?: string | null; updatedAt?: string } + +const TASK_MARK: Record = { not_started: '○', in_progress: '◐', done: '●' } + +export const me: Command = { + name: 'me', + aliases: ['my-work'], + summary: "What's waiting on you: reviews, assignments, overdue tasks, and work in flight", + usage: 'codeplans me [--product ]', + options: { product: { type: 'string' } }, + async run(ctx, _args, values) { + const client = ctx.client() + const productId = str(values.product) ? (await resolveProduct(client, str(values.product)!)).id : undefined + const work = await client.call('get_my_work', productId ? { productId } : {}) + if (ctx.out.json) return printJson(ctx.out, work) + const { out } = ctx + out.print(paint(out, 'bold', `Needs you (${work.needsYou.length})`)) + if (!work.needsYou.length) out.print(paint(out, 'dim', ' Nothing waiting on you.')) + for (const line of table(work.needsYou.map((i) => [ + ` ${i.urgent ? paint(out, 'red', i.verb) : paint(out, 'cyan', i.verb)}`, + truncate(i.title, 60), + paint(out, 'dim', [i.detail ?? i.reason, i.productName].filter(Boolean).join(' · ')), + ]))) out.print(line) + const groups = work.inFlight.taskGroups + if (groups.length) { + out.print() + out.print(paint(out, 'bold', 'Your tasks')) + for (const g of groups) { + out.print(` ${g.planTitle} ${paint(out, 'dim', `· ${g.productName} · plan ${g.planId}`)}`) + for (const line of table(g.tasks.map((t) => [` ${TASK_MARK[t.status] ?? '·'}`, t.overdue ? paint(out, 'red', t.title) : t.title, paint(out, 'dim', t.id)]))) out.print(line) + } + } + if (work.inFlight.plans.length) { + out.print() + out.print(paint(out, 'bold', 'Plans you own')) + for (const line of table(work.inFlight.plans.map((p) => [` ${p.title}`, p.status, `${Math.round(p.progress)}%`, p.atRisk ? paint(out, 'red', 'at risk') : '', paint(out, 'dim', p.id)]))) out.print(line) + } + }, +} + +export const products: Command = { + name: 'products', + summary: 'List the products you can see', + usage: 'codeplans products', + async run(ctx) { + const rows = await ctx.client().call('list_products') + if (ctx.out.json) return printJson(ctx.out, rows) + if (!rows.length) return ctx.out.print('No products yet.') + for (const line of table(rows.map((p) => [p.slug, p.name, String(p.assetCount ?? ''), String(p.activePlanCount ?? ''), String(p.workItemCount ?? '')]), ['SLUG', 'NAME', 'ASSETS', 'ACTIVE PLANS', 'WORK ITEMS'], ctx.out)) ctx.out.print(line) + }, +} + +export const product: Command = { + name: 'product', + summary: "One product's assets and their dependencies", + usage: 'codeplans product ', + minArgs: 1, + async run(ctx, [ref]) { + const client = ctx.client() + const slug = (await resolveProduct(client, ref)).slug + const p = await client.call('get_product', { slug }) + if (ctx.out.json) return printJson(ctx.out, p) + const { out } = ctx + out.print(`${paint(out, 'bold', p.name)} ${paint(out, 'dim', `(${p.slug})`)}`) + if (p.description) out.print(truncate(p.description, 160)) + out.print() + for (const line of table(p.assets.map((a) => [a.name, a.type, a.layer ?? '', a.health, String(a.openDebtCount ?? 0), (a.owners ?? []).map((o) => o.name).join(', ')]), ['ASSET', 'TYPE', 'LAYER', 'HEALTH', 'DEBT', 'CODE OWNERS'], out)) out.print(line) + if (p.dependencies.length) { + out.print() + out.print(paint(out, 'bold', 'Dependencies')) + for (const line of table(p.dependencies.map((d) => [` ${d.sourceAssetName}`, paint(out, 'dim', d.dependencyType.replace('_', ' ')), d.targetAssetName]))) out.print(line) + } + }, +} + +const PLAN_STATUSES = ['draft', 'active', 'completed', 'cancelled'] as const +const PLAN_TYPES = ['refactor', 'feature', 'improvement', 'bugfix'] as const + +export const plans: Command = { + name: 'plans', + summary: 'List code plans with progress', + usage: 'codeplans plans [--product ] [--status draft|active|completed|cancelled] [--type ]', + options: { product: { type: 'string' }, status: { type: 'string' }, type: { type: 'string' } }, + async run(ctx, _args, values) { + const client = ctx.client() + const filters: Record = {} + if (str(values.product)) filters.productId = (await resolveProduct(client, str(values.product)!)).id + const status = oneOf(str(values.status), PLAN_STATUSES, 'status') + const type = oneOf(str(values.type), PLAN_TYPES, 'type') + if (status) filters.status = status + if (type) filters.type = type + const rows = await client.call('list_code_plans', filters) + if (ctx.out.json) return printJson(ctx.out, rows) + if (!rows.length) return ctx.out.print('No plans match.') + for (const line of table(rows.map((p) => [ + p.id, truncate(p.title, 50), p.status, p.type, + p.taskCount ? `${p.completedTaskCount ?? 0}/${p.taskCount}` : '-', + p.productName ?? '', + ]), ['ID', 'TITLE', 'STATUS', 'TYPE', 'TASKS', 'PRODUCT'], ctx.out)) ctx.out.print(line) + }, +} + +export const plan: Command = { + name: 'plan', + summary: "A plan's tasks, assets and PRs, and the work items it addresses", + usage: 'codeplans plan ', + minArgs: 1, + async run(ctx, [id]) { + const p = await ctx.client().call('get_code_plan', { id }) + if (ctx.out.json) return printJson(ctx.out, p) + const { out } = ctx + out.print(`${paint(out, 'bold', p.title)} ${paint(out, 'dim', `· ${p.status} · ${p.type}${p.revision ? ` · rev ${p.revision}` : ''}${p.productName ? ` · ${p.productName}` : ''}`)}`) + if (p.description) { out.print(); out.print(truncate(p.description.replace(/\s+/g, ' '), 300)) } + const tasks = p.tasks ?? [] + out.print() + out.print(paint(out, 'bold', `Tasks (${tasks.filter((t) => t.status === 'done').length}/${tasks.length} done)`)) + if (!tasks.length) out.print(paint(out, 'dim', ' No tasks yet. Add one: codeplans task add ""')) + const names = new Map((p.assignees ?? []).map((a) => [a.id, a.name])) + for (const line of table(tasks.map((t) => [` ${TASK_MARK[t.status] ?? '·'}`, truncate(t.title, 60), t.priority, (t.assigneeId && names.get(t.assigneeId)) || '', paint(out, 'dim', t.id)]))) out.print(line) + const assets = p.planAssets ?? [] + if (assets.length) { + out.print() + out.print(paint(out, 'bold', 'Assets')) + for (const line of table(assets.map((a) => [` ${a.assetName}`, a.branch ?? '', a.prStatus && a.prStatus !== 'none' ? a.prStatus : '', a.prUrl ?? '']))) out.print(line) + } + const items = p.linkedWorkItems ?? [] + if (items.length) { + out.print() + out.print(paint(out, 'bold', 'Work items')) + for (const line of table(items.map((w) => [` ${truncate(w.title, 60)}`, w.type.replace('_', ' '), w.status.replace('_', ' '), paint(out, 'dim', w.id)]))) out.print(line) + } + }, +} + +const ITEM_TYPES = ['feature', 'bug', 'enhancement', 'ux', 'tech_debt'] as const +const ITEM_STATUSES = ['open', 'planned', 'in_progress', 'resolved', 'wont_do'] as const +const SEVERITIES = ['low', 'medium', 'high', 'critical'] as const + +export const items: Command = { + name: 'items', + summary: 'List work items (features, bugs, UX issues, tech debt)', + usage: 'codeplans items [--product <slug>] [--type <type>] [--status <status>] [--severity <s>] [--tag <tag>] [--plan <planId>] [--limit <n>] [--cursor <c>]', + options: { product: { type: 'string' }, type: { type: 'string' }, status: { type: 'string' }, severity: { type: 'string' }, tag: { type: 'string' }, plan: { type: 'string' }, limit: { type: 'string' }, cursor: { type: 'string' } }, + async run(ctx, _args, values) { + const client = ctx.client() + const args: Record<string, unknown> = {} + if (str(values.product)) args.productId = (await resolveProduct(client, str(values.product)!)).id + const type = oneOf(str(values.type), ITEM_TYPES, 'type') + const status = oneOf(str(values.status), ITEM_STATUSES, 'status') + const severity = oneOf(str(values.severity), SEVERITIES, 'severity') + if (type) args.type = type + if (status) args.status = status + if (severity) args.severity = severity + if (str(values.tag)) args.tag = str(values.tag) + if (str(values.plan)) args.planId = str(values.plan) + if (str(values.cursor)) args.cursor = str(values.cursor) + if (str(values.limit)) { + const n = Number(str(values.limit)) + if (!Number.isInteger(n) || n < 1 || n > 200) throw new CliError('--limit must be a whole number from 1 to 200', EXIT.usage) + args.limit = n + } + const res = await client.call<{ items: WorkItem[]; total: number; nextCursor: string | null }>('list_work_items', args) + if (ctx.out.json) return printJson(ctx.out, res) + if (!res.items.length) return ctx.out.print('No work items match.') + for (const line of table(res.items.map((w) => [ + w.id, truncate(w.title, 50), w.type.replace('_', ' '), severityLabel(ctx.out, w.severity), w.status.replace('_', ' '), + [w.asset, w.area].filter(Boolean).join(' · '), w.owner ?? '', ago(w.updatedAt), + ]), ['ID', 'TITLE', 'TYPE', 'SEVERITY', 'STATUS', 'ASSET / AREA', 'OWNER', 'UPDATED'], ctx.out)) ctx.out.print(line) + if (res.nextCursor) ctx.out.print(paint(ctx.out, 'dim', `Showing ${res.items.length} of ${res.total}. Narrow with filters, raise --limit (up to 200), or get the next page with --cursor ${res.nextCursor}.`)) + }, +} + +function severityLabel(out: Parameters<typeof paint>[0], s: string) { + return s === 'critical' || s === 'high' ? paint(out, s === 'critical' ? 'red' : 'yellow', s) : s +} + +const SEARCH_TYPES = ['product', 'asset', 'plan', 'task', 'work_item', 'spec', 'release'] as const + +export const search: Command = { + name: 'search', + summary: 'Search products, assets, plans, tasks, work items, specs and releases', + usage: 'codeplans search <words…> [--type <type>]… [--product <slug>] [--limit <n>]', + options: { type: { type: 'string', multiple: true }, product: { type: 'string' }, limit: { type: 'string' } }, + minArgs: 1, + async run(ctx, args, values) { + const client = ctx.client() + const query = args.join(' ') + if (query.trim().length < 2) throw new CliError('Search for at least 2 characters.', EXIT.usage) + const params: Record<string, unknown> = { query } + const types = list(values.type).map((t) => oneOf(t, SEARCH_TYPES, 'type')!) + if (types.length) params.types = types + if (str(values.product)) params.productId = (await resolveProduct(client, str(values.product)!)).id + if (str(values.limit)) params.limit = Number(str(values.limit)) + const res = await client.call<{ results: { type: string; id: string; title: string; productName: string; status: string | null; key: string | null }[]; counts?: Record<string, number> }>('search', params) + if (ctx.out.json) return printJson(ctx.out, res) + if (!res.results.length) return ctx.out.print(`Nothing matches "${query}".`) + for (const line of table(res.results.map((r) => [ + paint(ctx.out, 'dim', r.type.replace('_', ' ')), truncate(r.key ? `${r.key} ${r.title}` : r.title, 60), r.status?.replace('_', ' ') ?? '', paint(ctx.out, 'dim', r.productName), r.id, + ]))) ctx.out.print(line) + }, +} + +export const tools: Command = { + name: 'tools', + summary: 'List every MCP tool the server offers (use them with codeplans call)', + usage: 'codeplans tools', + async run(ctx) { + const rows = await ctx.client().listTools() + if (ctx.out.json) return printJson(ctx.out, rows) + for (const line of table(rows.map((t) => [t.name, truncate((t.description ?? '').split(/(?<=\.)\s/)[0], 90)]))) ctx.out.print(line) + }, +} diff --git a/cli/src/commands/write.ts b/cli/src/commands/write.ts new file mode 100644 index 0000000..24f373a --- /dev/null +++ b/cli/src/commands/write.ts @@ -0,0 +1,180 @@ +import { readFileSync } from 'node:fs' +import { CliError, EXIT, type Client } from '../client.js' +import { paint, printJson } from '../output.js' +import { list, oneOf, resolveProduct, str, type Command, type Values } from '../cli.js' + +type Created = { id: string; title?: string; status?: string } +type ProductAssets = { assets: { id: string; name: string }[] } + +const PLAN_TYPES = ['refactor', 'feature', 'improvement', 'bugfix'] as const +const ITEM_TYPES = ['feature', 'bug', 'enhancement', 'ux', 'tech_debt'] as const +const ITEM_STATUSES = ['open', 'planned', 'in_progress', 'resolved', 'wont_do'] as const +const PRIORITIES = ['low', 'medium', 'high', 'critical'] as const + +/** Asset ids from names or ids, within one product. */ +async function resolveAssets(client: Client, productSlug: string, refs: string[]): Promise<string[]> { + if (!refs.length) return [] + const { assets } = await client.call<ProductAssets>('get_product', { slug: productSlug }) + return refs.map((ref) => { + const match = assets.find((a) => a.id === ref) ?? assets.find((a) => a.name.toLowerCase() === ref.toLowerCase()) + if (!match) throw new CliError(`No asset "${ref}" in ${productSlug}. Assets: ${assets.map((a) => a.name).join(', ') || 'none'}.`, EXIT.usage) + return match.id + }) +} + +function done(ctx: { out: Parameters<typeof printJson>[0] }, data: unknown, message: string) { + if (ctx.out.json) return printJson(ctx.out, data) + ctx.out.print(`${paint(ctx.out, 'green', '✓')} ${message}`) +} + +/** Text from --description, or a file with --description-file (- reads stdin). */ +function description(values: Values): string | undefined { + const file = str(values['description-file']) + if (file) return readFileSync(file === '-' ? 0 : file, 'utf8') + return str(values.description) +} + +export const planCreate: Command = { + name: 'plan create', + summary: 'Create a draft code plan', + usage: 'codeplans plan create --product <slug> --title <title> [--type feature|refactor|improvement|bugfix] [--asset <name>]… [--item <workItemId>]… [--description <text> | --description-file <path|->] [--tag <tag>]…', + options: { + product: { type: 'string' }, title: { type: 'string' }, type: { type: 'string' }, asset: { type: 'string', multiple: true }, + item: { type: 'string', multiple: true }, description: { type: 'string' }, 'description-file': { type: 'string' }, tag: { type: 'string', multiple: true }, + owner: { type: 'string' }, deadline: { type: 'string' }, + }, + async run(ctx, _args, values) { + const productRef = str(values.product) + const title = str(values.title) + if (!productRef || !title) throw new CliError(`Usage: ${planCreate.usage}`, EXIT.usage) + const client = ctx.client() + const p = await resolveProduct(client, productRef) + const plan = await client.call<Created>('create_code_plan', { + productId: p.id, title, type: oneOf(str(values.type), PLAN_TYPES, 'type') ?? 'feature', + description: description(values) ?? '', tags: list(values.tag), + targetAssetIds: await resolveAssets(client, p.slug, list(values.asset)), workItemIds: list(values.item), + ...(str(values.owner) ? { ownerEmail: str(values.owner) } : {}), ...(str(values.deadline) ? { deadline: str(values.deadline) } : {}), + }) + done(ctx, plan, `Created draft plan "${title}" ${paint(ctx.out, 'dim', plan.id)}`) + }, +} + +export const planActivate: Command = { + name: 'plan activate', + summary: 'Move a draft plan to active (the review workflow may require an approval first)', + usage: 'codeplans plan activate <planId>', + minArgs: 1, + async run(ctx, [id]) { + const plan = await ctx.client().call<Created>('activate_plan', { id }) + done(ctx, plan, `Activated "${plan.title ?? id}"`) + }, +} + +export const planComplete: Command = { + name: 'plan complete', + summary: 'Mark a plan completed', + usage: 'codeplans plan complete <planId>', + minArgs: 1, + async run(ctx, [id]) { + const plan = await ctx.client().call<Created>('complete_plan', { id }) + done(ctx, plan, `Completed "${plan.title ?? id}"`) + }, +} + +export const taskAdd: Command = { + name: 'task add', + summary: 'Add a task to a plan (re-adding the same title does nothing, so scripts can re-run)', + usage: 'codeplans task add <planId> <title> [--priority low|medium|high|critical] [--assignee <email>] [--description <text> | --description-file <path|->]', + options: { priority: { type: 'string' }, assignee: { type: 'string' }, description: { type: 'string' }, 'description-file': { type: 'string' } }, + minArgs: 2, + async run(ctx, [codePlanId, ...words], values) { + const title = words.join(' ') + const task = await ctx.client().call<Created>('create_task', { + codePlanId, title, priority: oneOf(str(values.priority), PRIORITIES, 'priority') ?? 'medium', description: description(values) ?? '', + ...(str(values.assignee) ? { assigneeEmail: str(values.assignee) } : {}), + }) + done(ctx, task, `Task "${title}" ${paint(ctx.out, 'dim', task.id)}`) + }, +} + +function taskStatus(name: string, status: 'in_progress' | 'done' | 'not_started', verb: string, summary: string): Command { + return { + name, + summary, + usage: `codeplans ${name} <taskId>`, + minArgs: 1, + async run(ctx, [id]) { + const task = await ctx.client().call<Created>('update_task_status', { id, status }) + done(ctx, task, `${verb}: ${task.title ?? id}`) + }, + } +} +export const taskStart = taskStatus('task start', 'in_progress', 'Started', 'Start a task (the review workflow may require the plan to be approved)') +export const taskDone = taskStatus('task done', 'done', 'Done', 'Mark a task done') +export const taskReset = taskStatus('task reset', 'not_started', 'Reset', 'Move a task back to not started') + +export const itemAdd: Command = { + name: 'item add', + summary: 'File a work item', + usage: 'codeplans item add --product <slug> --title <title> --type feature|bug|enhancement|ux|tech_debt [--severity <s>] [--asset <name>] [--area <area>] [--description <text>] [--tag <tag>]…', + options: { + product: { type: 'string' }, title: { type: 'string' }, type: { type: 'string' }, severity: { type: 'string' }, asset: { type: 'string' }, + area: { type: 'string' }, description: { type: 'string' }, 'description-file': { type: 'string' }, tag: { type: 'string', multiple: true }, owner: { type: 'string' }, + }, + async run(ctx, _args, values) { + const productRef = str(values.product) + const title = str(values.title) + const type = oneOf(str(values.type), ITEM_TYPES, 'type') + if (!productRef || !title || !type) throw new CliError(`Usage: ${itemAdd.usage}`, EXIT.usage) + const client = ctx.client() + const p = await resolveProduct(client, productRef) + const [assetId] = await resolveAssets(client, p.slug, str(values.asset) ? [str(values.asset)!] : []) + const item = await client.call<Created>('create_work_item', { + productId: p.id, title, type, severity: oneOf(str(values.severity), PRIORITIES, 'severity') ?? 'medium', + description: description(values) ?? '', tags: list(values.tag), + ...(assetId ? { assetId } : {}), ...(str(values.area) ? { area: str(values.area) } : {}), ...(str(values.owner) ? { ownerEmail: str(values.owner) } : {}), + }) + done(ctx, item, `Filed "${title}" ${paint(ctx.out, 'dim', item.id)}`) + }, +} + +export const itemStatus: Command = { + name: 'item status', + summary: "Set a work item's status", + usage: 'codeplans item status <itemId> open|planned|in_progress|resolved|wont_do', + minArgs: 2, + async run(ctx, [id, status]) { + const item = await ctx.client().call<Created>('update_work_item_status', { id, status: oneOf(status, ITEM_STATUSES, 'status') }) + done(ctx, item, `${item.title ?? id} is now ${status.replace('_', ' ')}`) + }, +} + +/** "k=v": the value is parsed as JSON when it parses (numbers, booleans, arrays), else kept as a string. */ +export function parseArg(pair: string): [string, unknown] { + const eq = pair.indexOf('=') + if (eq < 1) throw new CliError(`--arg takes key=value, got "${pair}"`, EXIT.usage) + const key = pair.slice(0, eq) + const raw = pair.slice(eq + 1) + try { return [key, JSON.parse(raw)] } catch { return [key, raw] } +} + +export const call: Command = { + name: 'call', + summary: 'Call any MCP tool directly, for anything without its own command', + usage: 'codeplans call <tool> [--arg key=value]… [--input <file.json|->]', + details: 'Values given with --arg are parsed as JSON when they can be (e.g. --arg limit=5 --arg tags=\'["a"]\'), otherwise taken as text.\nThe result is printed as JSON. List the tools and their descriptions with: codeplans tools', + options: { arg: { type: 'string', multiple: true }, input: { type: 'string' } }, + minArgs: 1, + async run(ctx, [tool], values) { + let args: Record<string, unknown> = {} + const input = str(values.input) + if (input) { + try { args = JSON.parse(readFileSync(input === '-' ? 0 : input, 'utf8')) } catch (err) { + throw new CliError(`Can't read JSON from ${input}: ${err instanceof Error ? err.message : err}`, EXIT.usage) + } + } + for (const pair of list(values.arg)) { const [k, v] = parseArg(pair); args[k] = v } + const result = await ctx.client().call(tool, args) + printJson(ctx.out, result) + }, +} diff --git a/cli/src/config.ts b/cli/src/config.ts new file mode 100644 index 0000000..64978d1 --- /dev/null +++ b/cli/src/config.ts @@ -0,0 +1,80 @@ +// Connection profiles in ~/.config/codeplans/config.json (or $XDG_CONFIG_HOME), +// written with mode 0600. CODEPLANS_URL / CODEPLANS_API_KEY / CODEPLANS_PROFILE +// override the file, so CI needs no file at all. +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import path from 'node:path' +import { CliError, EXIT } from './client.js' + +/** Environment variables; a plain record so tests can pass a few. */ +export type Env = Record<string, string | undefined> + +export type Profile = { url: string; key: string } +export type Config = { current?: string; profiles: Record<string, Profile> } + +export function configPath(env: Env = process.env): string { + const base = env.XDG_CONFIG_HOME?.trim() || path.join(homedir(), '.config') + return path.join(base, 'codeplans', 'config.json') +} + +export function readConfig(file = configPath()): Config { + if (!existsSync(file)) return { profiles: {} } + try { + const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<Config> + return { current: parsed.current, profiles: parsed.profiles ?? {} } + } catch { + throw new CliError(`Can't read ${file}. Fix or delete it, then run codeplans login.`, EXIT.usage) + } +} + +export function writeConfig(config: Config, file = configPath()) { + mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }) + writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) + chmodSync(file, 0o600) +} + +export type Connection = Profile & { profile: string | null; source: 'env' | 'profile' } + +/** Where to connect: environment variables first, then the chosen (or current) profile. */ +export function resolveConnection(opts: { profile?: string }, env: Env = process.env, file = configPath(env)): Connection { + const profileName = opts.profile ?? env.CODEPLANS_PROFILE ?? undefined + if (env.CODEPLANS_URL && env.CODEPLANS_API_KEY && !opts.profile) { + return { url: normalizeUrl(env.CODEPLANS_URL), key: env.CODEPLANS_API_KEY, profile: null, source: 'env' } + } + const config = readConfig(file) + const name = profileName ?? config.current ?? 'default' + const profile = config.profiles[name] + if (!profile) { + throw new CliError( + Object.keys(config.profiles).length + ? `No profile named "${name}". Profiles: ${Object.keys(config.profiles).join(', ')}.` + : 'Not connected. Run: codeplans login --url https://your-codeplans --key cpk_… (or set CODEPLANS_URL and CODEPLANS_API_KEY).', + EXIT.auth, + ) + } + return { + url: normalizeUrl(env.CODEPLANS_URL ?? profile.url), + key: env.CODEPLANS_API_KEY ?? profile.key, + profile: name, + source: 'profile', + } +} + +/** HTTPS only, except for local development. */ +export function normalizeUrl(raw: string): string { + let url: URL + try { + url = new URL(raw.trim()) + } catch { + throw new CliError(`"${raw}" isn't a URL. Use the address you open CodePlans at, e.g. https://acme.codeplans.app`, EXIT.usage) + } + const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) { + throw new CliError('Use an https:// URL (http:// is allowed only for localhost).', EXIT.usage) + } + return url.origin + url.pathname.replace(/\/+$/, '') +} + +export function maskKey(key: string): string { + return key.length <= 10 ? '••••' : `${key.slice(0, 6)}…${key.slice(-4)}` +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000..9e695b5 --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { run } from './cli.js' +import { COMMANDS } from './commands.js' + +// Output piped into head, grep -m and the like: stop quietly when the reader goes away. +for (const stream of [process.stdout, process.stderr]) { + stream.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EPIPE') process.exit(0); throw err }) +} + +run(process.argv.slice(2), COMMANDS).then((code) => { process.exitCode = code }) diff --git a/cli/src/output.ts b/cli/src/output.ts new file mode 100644 index 0000000..79d383c --- /dev/null +++ b/cli/src/output.ts @@ -0,0 +1,64 @@ +// Human-readable output: aligned columns, light colour on a TTY (never with +// NO_COLOR), relative dates. --json bypasses all of this. +import type { Env } from './config.js' + +export type Out = { + json: boolean + print: (line?: string) => void + error: (line: string) => void + color: boolean +} + +/** Anything output can go to: process.stdout/stderr, or a test double. */ +export type Sink = { write: (chunk: string) => unknown; isTTY?: boolean } + +export function createOut(json: boolean, stream: Sink = process.stdout, err: Sink = process.stderr, env: Env = process.env): Out { + const color = !json && !!stream.isTTY && !env.NO_COLOR && env.TERM !== 'dumb' + return { + json, + color, + print: (line = '') => { stream.write(`${line}\n`) }, + error: (line) => { err.write(`${line}\n`) }, + } +} + +const codes = { bold: [1, 22], dim: [2, 22], red: [31, 39], green: [32, 39], yellow: [33, 39], cyan: [36, 39] } as const +export type Style = keyof typeof codes + +export function paint(out: Pick<Out, 'color'>, style: Style, text: string): string { + if (!out.color) return text + const [open, close] = codes[style] + return `\u001b[${open}m${text}\u001b[${close}m` +} + +const ANSI = /\u001b\[[0-9;]*m/g +const visible = (s: string) => s.replace(ANSI, '').length + +/** Columns padded to their widest cell; the last column isn't padded. */ +export function table(rows: string[][], headers?: string[], out?: Pick<Out, 'color'>): string[] { + const all = headers ? [headers.map((h) => (out ? paint(out, 'dim', h) : h)), ...rows] : rows + if (!all.length) return [] + const widths = all[0].map((_, i) => Math.max(...all.map((r) => visible(r[i] ?? '')))) + return all.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : cell + ' '.repeat(widths[i] - visible(cell)))).join(' ').trimEnd()) +} + +export function truncate(text: string, max: number): string { + return text.length <= max ? text : `${text.slice(0, max - 1)}…` +} + +export function ago(iso: string | null | undefined, now = Date.now()): string { + if (!iso) return '' + const t = new Date(iso).getTime() + if (Number.isNaN(t)) return '' + const s = Math.round((now - t) / 1000) + if (s < 0) return new Date(iso).toISOString().slice(0, 10) + if (s < 60) return 'just now' + const units: [number, string][] = [[60, 'm'], [3600, 'h'], [86400, 'd'], [604800, 'w']] + let label = '' + for (const [size, unit] of units) if (s >= size) label = `${Math.floor(s / size)}${unit} ago` + return s >= 2592000 ? new Date(iso).toISOString().slice(0, 10) : label +} + +export function printJson(out: Out, data: unknown) { + out.print(JSON.stringify(data, null, 2)) +} diff --git a/cli/test/apply.test.ts b/cli/test/apply.test.ts new file mode 100644 index 0000000..5399854 --- /dev/null +++ b/cli/test/apply.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { Client } from '../src/client' +import { applyManifest, slugify, validateManifest, type Manifest } from '../src/apply' +import { fakeServer } from './helpers' + +describe('validateManifest', () => { + it('accepts a minimal manifest', () => { + expect(validateManifest({ products: [{ name: 'Web' }] }).products).toHaveLength(1) + }) + + it.each([ + [null, /products/], + [{ products: [] }, /products: must be a non-empty list/], + [{ products: [{}] }, /products\[0\]\.name: is required/], + [{ products: [{ name: 'A', assets: [{ name: 'x', type: 'robot' }] }] }, /products\[0\]\.assets\[0\]\.type/], + ])('names the problem in %j', (raw, message) => { + expect(() => validateManifest(raw)).toThrow(message) + }) +}) + +describe('slugify', () => { + it('lowercases and dashes', () => expect(slugify(' Payments API v2 ')).toBe('payments-api-v2')) +}) + +/** An in-memory workspace behind the fake MCP endpoint. */ +function workspace() { + const products: { id: string; name: string; slug: string }[] = [{ id: 'p1', name: 'Web', slug: 'web' }] + const assets: { id: string; productId: string; name: string }[] = [{ id: 'a1', productId: 'p1', name: 'Frontend' }] + const deps: { sourceAssetId: string; targetAssetId: string; dependencyType: string }[] = [] + const people: { productId: string; email: string; responsibility: string; area: string | null }[] = [] + let n = 0 + const server = fakeServer({ + list_products: () => products, + get_product: ({ slug }) => { + const p = products.find((x) => x.slug === slug)! + const ids = assets.filter((a) => a.productId === p.id).map((a) => a.id) + return { ...p, assets: assets.filter((a) => a.productId === p.id), dependencies: deps.filter((d) => ids.includes(d.sourceAssetId)) } + }, + list_product_people: ({ productId }) => ({ members: people.filter((m) => m.productId === productId) }), + create_product: ({ name, slug }) => { const p = { id: `p${++n + 1}`, name: String(name), slug: String(slug) }; products.push(p); return p }, + create_asset: ({ productId, name }) => { + if (name === 'Broken') return { error: 'Asset name is not allowed' } + const a = { id: `a${++n + 1}`, productId: String(productId), name: String(name) }; assets.push(a); return a + }, + add_asset_dependency: (args) => { deps.push(args as (typeof deps)[number]); return { ok: true } }, + assign_responsibility: ({ productId, email, responsibility, area }) => { + people.push({ productId: String(productId), email: String(email), responsibility: String(responsibility), area: (area as string) ?? null }); return { ok: true } + }, + }) + return { client: new Client('https://x.test', 'k', server.fetch), server } +} + +const manifest: Manifest = { + products: [ + { + name: 'Web', + assets: [{ name: 'frontend', type: 'app' }, { name: 'API', type: 'service' }], + dependencies: [{ from: 'Frontend', to: 'API' }], + people: [{ email: 'ana@example.com', responsibility: 'architect' }], + }, + { name: 'Mobile App', assets: [{ name: 'iOS', type: 'app' }] }, + ], +} + +describe('applyManifest', () => { + it('reports what a dry run would create without writing', async () => { + const { client, server } = workspace() + const results = await applyManifest(client, manifest, { dryRun: true }) + expect(results.map((r) => `${r.kind} ${r.name} ${r.status}`)).toEqual([ + 'product Web exists', 'asset frontend exists', 'asset API would create', 'dependency Frontend → API would create', + 'person ana@example.com (architect) would create', 'product Mobile App would create', 'asset iOS would create', + ]) + expect(server.calls.filter((c) => /^(create|add|assign)_/.test(c.name))).toEqual([]) + }) + + it('creates only what is missing, and is a no-op the second time', async () => { + const { client } = workspace() + const first = await applyManifest(client, manifest) + expect(first.filter((r) => r.status === 'created')).toHaveLength(5) + expect(first.find((r) => r.name === 'Mobile App')?.product).toBe('mobile-app') + const second = await applyManifest(client, manifest) + expect(second.every((r) => r.status === 'exists')).toBe(true) + }) + + it('keeps going after a failure and reports it', async () => { + const { client } = workspace() + const results = await applyManifest(client, { + products: [{ name: 'Web', assets: [{ name: 'Broken', type: 'app' }, { name: 'Ok', type: 'app' }], dependencies: [{ from: 'Ok', to: 'Ghost' }] }], + }) + expect(results.find((r) => r.name === 'Broken')).toMatchObject({ status: 'failed', error: 'Asset name is not allowed' }) + expect(results.find((r) => r.name === 'Ok')?.status).toBe('created') + expect(results.find((r) => r.kind === 'dependency')).toMatchObject({ status: 'failed', error: 'No asset named "Ghost" in web' }) + }) +}) diff --git a/cli/test/cli.test.ts b/cli/test/cli.test.ts new file mode 100644 index 0000000..56e9d88 --- /dev/null +++ b/cli/test/cli.test.ts @@ -0,0 +1,92 @@ +import { writeFileSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { run, VERSION } from '../src/cli' +import { COMMANDS } from '../src/commands' +import { parseArg } from '../src/commands/write' +import { capture, fakeServer } from './helpers' + +const env = { CODEPLANS_URL: 'https://cp.test', CODEPLANS_API_KEY: 'cpk_test' } +const dirs: string[] = [] +afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) + +async function cli(argv: string[], handlers: Parameters<typeof fakeServer>[0] = {}, extraEnv: Record<string, string> = env) { + const stdout = capture() + const stderr = capture() + const server = fakeServer(handlers) + const code = await run(argv, COMMANDS, { env: extraEnv, stdout, stderr, fetch: server.fetch }) + return { code, out: stdout.text(), err: stderr.text(), calls: server.calls } +} + +const products = () => [{ id: 'p1', name: 'Web', slug: 'web', assetCount: 2, activePlanCount: 1, workItemCount: 3 }] + +describe('run', () => { + it('prints help and the version', async () => { + expect((await cli([])).out).toMatch(/Usage: codeplans <command>/) + expect((await cli(['--version'])).out.trim()).toBe(VERSION) + expect((await cli(['help', 'plan', 'create'])).out).toMatch(/codeplans plan create --product/) + expect((await cli(['items', '--help'])).out).toMatch(/codeplans items/) + }) + + it('exits 2 on usage mistakes', async () => { + expect((await cli(['bogus'])).code).toBe(2) + expect((await cli(['products', '--nope'])).code).toBe(2) + expect((await cli(['plan'])).code).toBe(2) + expect((await cli(['item', 'status', 'i1', 'done'])).err).toMatch(/must be one of/) + }) + + it('exits 3 when not connected', async () => { + const xdg = mkdtempSync(path.join(tmpdir(), 'codeplans-cli-')) + dirs.push(xdg) + const r = await cli(['products'], {}, { XDG_CONFIG_HOME: xdg }) + expect(r.code).toBe(3) + expect(r.err).toMatch(/codeplans login/) + }) + + it('lists products as a table, or JSON with --json', async () => { + const table = await cli(['products'], { list_products: products }) + expect(table.code).toBe(0) + expect(table.out).toMatch(/^SLUG\s+NAME/) + expect(table.out).toMatch(/web\s+Web/) + const json = await cli(['products', '--json'], { list_products: products }) + expect(JSON.parse(json.out)).toEqual(products()) + }) + + it('picks the two-word command and passes options through', async () => { + const r = await cli(['plan', 'create', '--product', 'web', '--title', 'New thing', '--tag', 'cli', '--json'], { + list_products: products, + create_code_plan: (args) => ({ id: 'plan1', ...args }), + }) + expect(r.code).toBe(0) + expect(r.calls.at(-1)).toEqual({ + name: 'create_code_plan', + args: { productId: 'p1', title: 'New thing', type: 'feature', description: '', tags: ['cli'], targetAssetIds: [], workItemIds: [] }, + }) + }) + + it('turns a tool error into exit 1 on stderr', async () => { + const r = await cli(['task', 'start', 't1'], { update_task_status: () => { throw new Error('This plan needs an approved review before work starts.') } }) + expect(r.code).toBe(1) + expect(r.err).toMatch(/approved review/) + }) + + it('calls any tool with call', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codeplans-cli-')) + dirs.push(dir) + const input = path.join(dir, 'args.json') + writeFileSync(input, JSON.stringify({ productId: 'p1', limit: 1 })) + const r = await cli(['call', 'list_work_items', '--input', input, '--arg', 'limit=5', '--arg', 'status=open'], { list_work_items: (args) => ({ args }) }) + expect(JSON.parse(r.out)).toEqual({ args: { productId: 'p1', limit: 5, status: 'open' } }) + }) +}) + +describe('parseArg', () => { + it('parses JSON values and keeps the rest as text', () => { + expect(parseArg('limit=5')).toEqual(['limit', 5]) + expect(parseArg('tags=["a","b"]')).toEqual(['tags', ['a', 'b']]) + expect(parseArg('title=hello world')).toEqual(['title', 'hello world']) + expect(parseArg('expr=a=b')).toEqual(['expr', 'a=b']) + expect(() => parseArg('=x')).toThrow(/key=value/) + }) +}) diff --git a/cli/test/client.test.ts b/cli/test/client.test.ts new file mode 100644 index 0000000..263ec37 --- /dev/null +++ b/cli/test/client.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { Client, CliError, EXIT, parseResponse } from '../src/client' +import { fakeServer } from './helpers' + +const failure = (p: Promise<unknown>) => p.then(() => { throw new Error('expected a failure') }, (e: CliError) => e) + +describe('parseResponse', () => { + it('reads a JSON body', () => { + expect(parseResponse('{"jsonrpc":"2.0","id":3,"result":{"ok":true}}', 'application/json', 3).result).toEqual({ ok: true }) + }) + + it('reads the matching message from an event stream', () => { + const body = 'event: message\ndata: {"jsonrpc":"2.0","method":"notifications/progress"}\n\nevent: message\ndata: {"jsonrpc":"2.0","id":7,"result":{"n":1}}\n\n' + expect(parseResponse(body, 'text/event-stream', 7).result).toEqual({ n: 1 }) + }) + + it('rejects a body that is not JSON', () => { + expect(() => parseResponse('<html>', 'text/html', 1)).toThrow(/Unexpected response/) + }) +}) + +describe('Client', () => { + it('returns parsed tool results over JSON and SSE', async () => { + for (const sse of [false, true]) { + const server = fakeServer({ list_products: () => [{ slug: 'web' }] }, { sse }) + const client = new Client('https://cp.example.com/', 'cpk_test', server.fetch) + expect(await client.call('list_products')).toEqual([{ slug: 'web' }]) + } + }) + + it('throws a tool result carrying an error', async () => { + const server = fakeServer({ get_product: () => ({ error: 'Product not found' }) }) + await expect(new Client('https://x.test', 'k', server.fetch).call('get_product')).rejects.toThrow('Product not found') + }) + + it('throws a JSON-RPC error with exit code 1', async () => { + const server = fakeServer({}) + const err = await failure(new Client('https://x.test', 'k', server.fetch).call('missing')) + expect(err).toBeInstanceOf(CliError) + expect(err.exitCode).toBe(EXIT.failed) + }) + + it('maps a refused key and an unreachable server to exit code 3', async () => { + const refused = await failure(new Client('https://x.test', 'k', fakeServer({}, { status: 401 }).fetch).call('x')) + expect(refused.exitCode).toBe(EXIT.auth) + const down = await failure(new Client('https://x.test', 'k', async () => { throw new TypeError('fetch failed') }).call('x')) + expect(down.exitCode).toBe(EXIT.auth) + expect(down.message).toMatch(/Could not reach/) + }) + + it('posts to the MCP endpoint with the bearer key', async () => { + let seen: { url: string; auth: string } | undefined + const client = new Client('https://x.test/', 'cpk_abc', async (url, init) => { + seen = { url, auth: (init.headers as Record<string, string>).authorization } + return new Response('{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}', { headers: { 'content-type': 'application/json' } }) + }) + await client.listTools() + expect(seen).toEqual({ url: 'https://x.test/api/mcp/mcp', auth: 'Bearer cpk_abc' }) + }) +}) diff --git a/cli/test/config.test.ts b/cli/test/config.test.ts new file mode 100644 index 0000000..b3da14a --- /dev/null +++ b/cli/test/config.test.ts @@ -0,0 +1,74 @@ +import { mkdtempSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { maskKey, normalizeUrl, readConfig, resolveConnection, writeConfig } from '../src/config' + +const dirs: string[] = [] +const tempFile = () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codeplans-cli-')) + dirs.push(dir) + return path.join(dir, 'codeplans', 'config.json') +} +afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) + +describe('normalizeUrl', () => { + it('keeps https and trims trailing slashes', () => { + expect(normalizeUrl('https://acme.codeplans.app/')).toBe('https://acme.codeplans.app') + }) + it('allows http only for localhost', () => { + expect(normalizeUrl('http://localhost:3000')).toBe('http://localhost:3000') + expect(() => normalizeUrl('http://example.com')).toThrow(/https/) + expect(() => normalizeUrl('not a url')).toThrow(/isn't a URL/) + }) +}) + +describe('maskKey', () => { + it('shows only the ends of a key', () => { + expect(maskKey('cpk_1234567890abcdef')).toBe('cpk_12…cdef') + expect(maskKey('short')).toBe('••••') + }) +}) + +describe('config file', () => { + it('is written readable only by its owner', () => { + const file = tempFile() + writeConfig({ current: 'default', profiles: { default: { url: 'https://a.test', key: 'cpk_x' } } }, file) + expect(statSync(file).mode & 0o777).toBe(0o600) + expect(readConfig(file).current).toBe('default') + }) + + it('reads as empty when missing', () => { + expect(readConfig(tempFile())).toEqual({ profiles: {} }) + }) +}) + +describe('resolveConnection', () => { + const file = () => { + const f = tempFile() + writeConfig({ current: 'work', profiles: { work: { url: 'https://work.test', key: 'cpk_work' }, home: { url: 'https://home.test', key: 'cpk_home' } } }, f) + return f + } + + it('prefers the environment when both variables are set', () => { + const conn = resolveConnection({}, { CODEPLANS_URL: 'https://env.test', CODEPLANS_API_KEY: 'cpk_env' }, file()) + expect(conn).toMatchObject({ url: 'https://env.test', key: 'cpk_env', source: 'env' }) + }) + + it('uses the current profile, or the one named', () => { + const f = file() + expect(resolveConnection({}, {}, f)).toMatchObject({ url: 'https://work.test', profile: 'work' }) + expect(resolveConnection({ profile: 'home' }, {}, f)).toMatchObject({ key: 'cpk_home', profile: 'home' }) + expect(resolveConnection({}, { CODEPLANS_PROFILE: 'home' }, f)).toMatchObject({ profile: 'home' }) + }) + + it('lets --profile win over the environment', () => { + const conn = resolveConnection({ profile: 'home' }, { CODEPLANS_URL: 'https://env.test', CODEPLANS_API_KEY: 'cpk_env' }, file()) + expect(conn.source).toBe('profile') + }) + + it('explains how to connect when nothing is set up', () => { + expect(() => resolveConnection({}, {}, tempFile())).toThrow(/codeplans login/) + expect(() => resolveConnection({ profile: 'nope' }, {}, file())).toThrow(/Profiles: work, home/) + }) +}) diff --git a/cli/test/helpers.ts b/cli/test/helpers.ts new file mode 100644 index 0000000..3f4c1d3 --- /dev/null +++ b/cli/test/helpers.ts @@ -0,0 +1,36 @@ +import type { FetchLike } from '../src/client' +import type { Sink } from '../src/output' + +/** A fake MCP endpoint: each tools/call is answered by the matching handler. */ +export function fakeServer(handlers: Record<string, (args: Record<string, unknown>) => unknown>, opts: { sse?: boolean; status?: number } = {}) { + const calls: { name: string; args: Record<string, unknown> }[] = [] + const fetchImpl: FetchLike = async (_url, init) => { + if (opts.status) return new Response('nope', { status: opts.status }) + const req = JSON.parse(String(init.body)) as { id: number; method: string; params: { name: string; arguments: Record<string, unknown> } } + let message: unknown + if (req.method === 'tools/list') { + message = { jsonrpc: '2.0', id: req.id, result: { tools: Object.keys(handlers).map((name) => ({ name, description: `${name} tool` })) } } + } else { + const { name, arguments: args } = req.params + calls.push({ name, args }) + const handler = handlers[name] + if (!handler) message = { jsonrpc: '2.0', id: req.id, error: { code: -32602, message: `Tool ${name} not found` } } + else { + try { + message = { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: JSON.stringify(handler(args)) }] } } + } catch (err) { + message = { jsonrpc: '2.0', id: req.id, error: { code: -32603, message: (err as Error).message } } + } + } + } + return opts.sse + ? new Response(`event: message\ndata: ${JSON.stringify(message)}\n\n`, { headers: { 'content-type': 'text/event-stream' } }) + : new Response(JSON.stringify(message), { headers: { 'content-type': 'application/json' } }) + } + return { fetch: fetchImpl, calls } +} + +export function capture(): Sink & { text: () => string } { + let buf = '' + return { write: (chunk: string) => { buf += chunk; return true }, isTTY: false, text: () => buf } +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..f138402 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "outDir": "dist", + "rootDir": "src", + "declaration": false, + "sourceMap": false, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/docs/docs.html b/docs/docs.html index b2dafdf..6384e33 100644 --- a/docs/docs.html +++ b/docs/docs.html @@ -135,6 +135,12 @@ <h3>The Asset Atlas</h3> <p>The live system map: lenses for health, debt, and activity, blast-radius hover, and the grid and table views beside it.</p> <div class="doc-arrow">Read → </div> </a> + <a class="doc-card" href="https://github.com/SylonZero/CodePlans/blob/master/docs/guides/cli.md"> + <div class="doc-tag">Guide</div> + <h3>Command-line tool</h3> + <p>Install <code>@codeplans/cli</code>, work with plans, tasks and work items from the terminal, and set up workspaces from a manifest with <code>codeplans apply</code>.</p> + <div class="doc-arrow">Read → </div> + </a> <a class="doc-card" href="https://github.com/SylonZero/CodePlans/blob/master/docs/guides/ai-agents.md"> <div class="doc-tag">Guide</div> <h3>Working with AI Agents</h3> diff --git a/docs/guides/cli.md b/docs/guides/cli.md new file mode 100644 index 0000000..b820edf --- /dev/null +++ b/docs/guides/cli.md @@ -0,0 +1,232 @@ +# Command-line tool + +`codeplans` is CodePlans in your terminal. Use it to check what's waiting on +you, script workspace setups, and drive plans, tasks and work items from shell +scripts and CI. + +It talks to the same [MCP endpoint](ai-agents.md) your coding agents use, +with an API key, so every rule in the app applies unchanged: org access, the +review workflow, and protected fields on mirrored issues. Anything the CLI +has no command for is one `codeplans call` away. + +## Install + +The CLI needs Node.js 20 or later and has no other dependencies. + +```bash +npm install -g @codeplans/cli +codeplans --version +``` + +Or run it without installing: `npx @codeplans/cli status`. + +## Connect + +Create an API key in **Settings → API Keys**. Read keys cover the read +commands, and the commands that change things need a write key. Then: + +```bash +codeplans login --url https://acme.codeplans.app --key cpk_… +codeplans status +``` + +`login` checks the key before saving it to `~/.config/codeplans/config.json` +(or `$XDG_CONFIG_HOME/codeplans/config.json`). That file is readable only by +you. + +### Profiles + +Save more than one connection with `--profile`. The last one you log in to +becomes the current one. + +```bash +codeplans login --url http://localhost:3000 --key cpk_… --profile local +codeplans products --profile local +codeplans logout --profile local +``` + +### Environment variables (CI) + +In CI you don't need a config file at all. Set both of these: + +| Variable | Meaning | +|---|---| +| `CODEPLANS_URL` | The address you open CodePlans at | +| `CODEPLANS_API_KEY` | An API key | +| `CODEPLANS_PROFILE` | Use this saved profile instead of the current one | + +`CODEPLANS_URL` and `CODEPLANS_API_KEY` win over the config file when both +are set, unless you pass `--profile`. + +URLs must use `https://`. Plain `http://` is accepted only for `localhost`. + +## Commands + +Run `codeplans help` for the list and `codeplans <command> --help` for one +command's options. Products can be given by slug, name or id. + +### Reading + +| Command | What it shows | +|---|---| +| `codeplans me [--product <slug>]` | Reviews waiting on you, your assignments, overdue tasks and work in flight | +| `codeplans products` | Products with asset, plan and work item counts | +| `codeplans product <slug>` | A product's assets and dependencies | +| `codeplans plans [--product] [--status] [--type]` | Plans with progress | +| `codeplans plan <planId>` | A plan's tasks, target assets with branches and PRs, and linked work items | +| `codeplans items [--product] [--type] [--status] [--severity] [--tag] [--plan] [--limit] [--cursor]` | Work items. When there are more, it prints the `--cursor` for the next page | +| `codeplans search <words…> [--type]… [--product] [--limit]` | Text search across products, assets, plans, tasks, work items, specs and releases | +| `codeplans tools` | Every MCP tool the server offers | + +### Changing things (write key) + +| Command | What it does | +|---|---| +| `codeplans plan create --product <slug> --title <t> [--type] [--asset <name>]… [--item <id>]… [--description … \| --description-file <path\|->] [--tag]… [--owner <email>] [--deadline <date>]` | Creates a draft plan. Assets are given by name | +| `codeplans plan activate <planId>` | Moves a draft plan to active | +| `codeplans plan complete <planId>` | Marks a plan completed | +| `codeplans task add <planId> <title…> [--priority] [--assignee <email>] [--description]` | Adds a task. Re-adding the same title does nothing, so scripts can run again | +| `codeplans task start\|done\|reset <taskId>` | Sets a task's status | +| `codeplans item add --product <slug> --title <t> --type <type> [--severity] [--asset <name>] [--area] [--description] [--tag]…` | Files a work item | +| `codeplans item status <itemId> <status>` | Sets a work item's status: `open`, `planned`, `in_progress`, `resolved` or `wont_do` | +| `codeplans apply <manifest.json\|-> [--dry-run]` | Sets up products, assets, dependencies and responsibilities; see below | +| `codeplans call <tool> [--arg key=value]… [--input <file.json\|->]` | Calls any MCP tool and prints its JSON result | + +If your workspace uses the **Gated** review workflow, `plan activate` and +`task start` fail with the server's message until the plan's review is +approved. Nothing is bypassed. + +### `call`: everything else + +`codeplans tools` lists the tools. `call` passes arguments with `--arg`; values +are read as JSON when they parse, otherwise as text: + +```bash +codeplans call get_tech_debt_register --arg productId=… --arg limit=10 +codeplans call update_plan_asset --arg id=… --arg prUrl=https://github.com/acme/web/pull/42 --arg prStatus=open +echo '{"query":"billing","limit":5}' | codeplans call search --input - +``` + +## Output and scripting + +- Add `--json` to any command for the server's JSON, ready for `jq`: + + ```bash + codeplans plans --status active --json | jq -r '.[].title' + ``` + +- Tables are aligned plain text. Colour is used only on a terminal, and never + when `NO_COLOR` is set. +- Errors go to stderr, so a script can tell output from failure. + +| Exit code | Meaning | +|---|---| +| `0` | Success | +| `1` | The server refused or the command failed (for example a validation or review-workflow error) | +| `2` | Usage mistake: unknown command or option, or a bad value | +| `3` | Not connected, unreachable, or the API key was refused | + +## Setting up a workspace with `apply` + +`apply` reads a manifest describing products, their assets, the dependencies +between them, and who is responsible for what. It creates whatever is +missing. Running it again is safe: + +- products match by slug (made from the name unless you give one); +- assets match by name within their product, ignoring case; +- dependencies match by their two assets and type; +- responsibilities match by person, role and area. + +`apply` never changes or deletes anything that already exists. Preview with +`--dry-run`: + +```bash +codeplans apply workspace.json --dry-run +codeplans apply workspace.json +``` + +```text ++ product payments: Payments created ++ asset payments: Payments API created += asset payments: Ledger DB exists ++ dependency payments: Payments API → Ledger DB created ++ person payments: lisa@acme.com (architect, payments) created + +4 created, 1 already there. +``` + +If one step fails, `apply` reports it and carries on with the rest, then exits +with `1`. + +### Manifest format + +```json +{ + "products": [ + { + "name": "Payments", + "slug": "payments", + "description": "Card and bank payments", + "tags": ["fintech"], + "assets": [ + { "name": "Payments API", "type": "service", "layer": "backend", + "repositoryUrl": "https://github.com/acme/platform", "repoPath": "services/payments", + "owners": ["mike@acme.com"] }, + { "name": "Ledger DB", "type": "datastore", "layer": "data" }, + { "name": "Checkout Web", "type": "app", "layer": "frontend" } + ], + "dependencies": [ + { "from": "Payments API", "to": "Ledger DB" }, + { "from": "Checkout Web", "to": "Payments API", "type": "integrates_with" } + ], + "people": [ + { "email": "sarah@acme.com", "responsibility": "eng_manager" }, + { "email": "lisa@acme.com", "responsibility": "architect", "area": "payments" } + ] + } + ] +} +``` + +| Field | Required | Notes | +|---|---|---| +| `products[].name` | yes | | +| `products[].slug` | no | Defaults to the name in lowercase with dashes | +| `products[].description`, `tags` | no | Used only when the product is created | +| `assets[].name` | yes | | +| `assets[].type` | yes | `app`, `service`, `library`, `datastore` or `platform` | +| `assets[].layer`, `description`, `tags`, `repositoryUrl`, `repoPath`, `documentationUrl` | no | Used only when the asset is created | +| `assets[].owners` | no | Code owners by email, set when the asset is created | +| `dependencies[].from`, `to` | yes | Asset names in the same product | +| `dependencies[].type` | no | `depends_on` (default), `integrates_with` or `aggregates` | +| `people[].email` | yes | A member of the workspace | +| `people[].responsibility` | yes | `eng_manager`, `architect` or `contributor` | +| `people[].area` | no | Scopes the responsibility to an area | + +A dependency can point at an asset that already exists in the product, even +if the manifest doesn't list it. `apply -` reads the manifest from stdin. + +## Recipes + +Check what's waiting on you when a shell opens. Add this to `~/.zshrc`: + +```bash +codeplans me 2>/dev/null +``` + +Open a plan and its tasks from a script: + +```bash +plan=$(codeplans plan create --product payments --title "Retry failed payouts" \ + --asset "Payments API" --description-file plan.md --json | jq -r .id) +codeplans task add "$plan" "Add retry queue" --priority high +codeplans task add "$plan" "Alert on repeated failures" +codeplans plan activate "$plan" +``` + +File a work item from CI when a check finds debt: + +```bash +codeplans item add --product payments --type tech_debt --severity low \ + --title "Upgrade node-fetch in Payments API" --asset "Payments API" --tag deps +``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 423ec68..e8f3a79 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,5 +17,5 @@ export default defineConfig([ 'react-hooks/purity': 'warn', }, }, - globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts', 'refs/**', 'coverage/**', 'docs/**', 'lib/db/migrations/**', 'plugins/**']), + globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts', 'refs/**', 'coverage/**', 'docs/**', 'lib/db/migrations/**', 'plugins/**', 'cli/dist/**']), ]) diff --git a/package.json b/package.json index fb7985b..daebff2 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "cli:build": "npm run build --prefix cli", + "cli:pack": "npm run build --prefix cli && cd cli && npm pack --dry-run", "specs:migrate": "node --env-file=.env.local --import=tsx scripts/migrate-specs.ts" }, "dependencies": {