CLI with Full Tool Parity for workspace-lite¶
| Field | Value |
|---|---|
| Document | Full design: CLI with full tool parity |
| Author | TBD |
| Date | 2026-07-16 (rev 4 locked) |
| Status | Ready for implementation / Approved |
| Repo | /Users/joe/Documents/Github/workspace-lite (joe-broadhead/workspace-lite) |
| Baseline | master @ 60c58a7 |
| Scope | New CLI package + tool catalog SSOT; no second Google auth stack |
| Revision | 4 — open questions locked |
Overview¶
workspace-lite today exposes 218 Google Workspace tools through eight local TypeScript MCP servers that POST to Apps Script web app proxies. Agents and MCP clients already share one execution path (createProxyClient → HTTPS bearer token → Policy/Auth → Workspace APIs). Humans and scripts have no first-class frontend: operators either drive MCP through OpenCode or craft raw proxy POSTs.
This design introduces a catalog-driven CLI that is a second frontend over the same proxy client, Zod schemas, risk classification, and confirmation semantics—not a parallel implementation. Tool metadata (name, description, service, proxy action, schema, formatters, batch eligibility) becomes a single source of truth consumed by both MCP registration and CLI command generation. Static/dynamic client token risk lives in one module (shared/src/catalog/risk.ts), not duplicated on every tool. Full parity means equivalent actions, parameters, auth, errors, and confirm gates; UX may differ (flags/tables vs JSON-RPC/content[]).
The CLI is complementary to MCP, not a replacement. Setup (scripts/setup.sh, clasp deploy, bootstrap) remains unchanged; the CLI may later wrap operator convenience commands without rewriting clasp.
Migration principle: per-service port + MCP flip + delete old tools in one PR (vertical slice). No long-lived dual-write of 218 tools. CLI gains surface as each service flips; full 218-tool parity is the end state, not the first CLI MVP acceptance gate.
Background & Motivation¶
Current architecture (verified)¶
OpenCode / MCP client
↔ local TypeScript MCP server (stdio) packages/<service>/src/index.ts
↔ Apps Script web app (HTTPS + token) packages/<service>/apps-script/
↔ Google APIs as USER_DEPLOYING
| Layer | Location | Role |
|---|---|---|
| Service registry | config/service-registry.json | Service keys, tool counts, OAuth scopes, token env names |
| Shared client | shared/src/proxy-client.ts | createProxyClient(service), token class routing, 30s timeout |
| Schemas | shared/src/schemas.ts (~1661 lines) | Zod input shapes, confirm, idempotencyKey |
| Response | shared/src/response.ts | ProxyResponse, formatResponse, formatList, truncation |
| Tool helper | shared/src/tool-helpers.ts | ToolDef + registerTool (~55 call sites) |
| Raw tools | packages/*/src/tools/*.ts | server.tool(...) (~163 call sites) with custom formatters |
| Policy | packages/*/apps-script/Policy.gs | Confirm for send/share/destructive; allowlists; rate weights |
| Auth | shared/apps-script/Auth.gs (mirrored) | Bearer tokens + class-scoped tokens |
| Validators | scripts/validate-architecture.mjs, registry-helpers.mjs | Auth/Response identity, tool↔action mappings, counts |
| Tests | tests/tool-registration.test.ts | Expected counts, docs/skill catalog alignment |
| MCP bins | e.g. google-workspace-drive | Per-service stdio servers |
Tool surface by service: drive 44, gmail 39, calendar 22, sheets 33, slides 25, docs 26, tasks 13, forms 16 (218 total).
Pain points¶
- No human/script CLI — operators debugging proxy responses or automating outside OpenCode lack a supported interface.
- Dual registration styles —
registerToolvs hand-writtenserver.toolhandlers make discovery and mechanical generation hard;toolActionMappings()already regex-parses both patterns inscripts/registry-helpers.mjs. - Metadata fragmentation — name/description/schema/action live in TS tool files; risk class lives in Apps Script
ACTION_POLICIESand partially inACTION_TOKEN_CLASSESinproxy-client.ts; docs andskills/google-workspace/references/tool-catalog.mdare kept in sync by tests, not generated from one catalog. - Docs vs code security default —
docs/architecture/security.mdstates primary token defaults toread,draft, butDEFAULT_AUTH_TOKEN_CLASSESin Auth.gs isread,draft,write,destructive,share,send. Any CLI design must not paper over this: server policy is the real gate, and operators must understand class tokens vs primary token breadth.
Why not a thin wrapper¶
A one-off Commander wrapper that hardcodes 218 commands would:
- Drift from MCP tool definitions within weeks
- Bypass or reimplement Zod validation inconsistently
- Duplicate confirm/idempotency UX without shared risk metadata
- Force dual maintenance of every new tool
First principles require catalog → MCP + CLI, not CLI bolted onto today’s handlers.
Batch confirm semantics (verified at baseline)¶
Critical for CLI design — verified in packages/*/apps-script/*Service.gs + Policy.gs:
- Outer action
batchhasACTION_POLICIES.batch.class = 'read'. runBatchcallshandleFn(op.action, op.params || {})per operation.- Each nested call runs
enforceActionPolicy(action, params, …)which requiresparams.confirm === trueon that op when resolved class is send/share/destructive. - Top-level
params.confirmon the batch request is not consulted for nested ops. - Client
batchTokenClassmay return'admin'when multiple high-risk classes mix — that only affects token selection, not Policy confirm.
Therefore CLI --yes / prompts must inject confirm: true into each gated operation’s params, not only outer batch args.
Goals & Non-Goals¶
Goals¶
- Full tool parity architecture — every registered MCP tool is invokable from the CLI with equivalent semantics (params, auth, confirm, errors, batch partial results). End-state after all service flips; CLI MVP may ship with a subset.
- Single source of truth for tool definitions consumed by MCP registration and CLI — no long dual-write window.
- Incremental migration via per-service vertical slices (port + flip + delete) without a monorepo-wide big-bang.
- Navigable UX for 218 tools (service subcommands, generated help, generic call).
- Human + machine output (
--json, tables/text, structured exit codes). - Safety non-regression — same confirm gates including per-op batch confirm;
--yesonly injects client-sideconfirm: true; Policy.gs remains authoritative. - Testable parity — registration tests, invoke-path parity (CLI vs MCP → same
callProxyargs), contract/golden CLI tests (prefer--json). - Distributable package — npm workspace package with bin
wslite(notwslorgws). - Incremental PR plan — independently reviewable vertical slices.
Non-Goals¶
- Replacing MCP servers or OpenCode integration.
- A second OAuth / Google auth stack (no service accounts, no gcloud ADC path for Workspace calls).
- Rewriting clasp setup, bootstrap, or Apps Script deployment in the first ship of the CLI.
- Day-one hand-crafted “pretty” UX sugar for every flag of every tool (architecture must allow progressive sugar).
- Multi-user SaaS identity; still one deploying-user proxy per service.
- Changing Apps Script action implementations (except optional metadata exports if needed later).
- Publishing shared/cli to public npm before CLI GA (monorepo-local only for now; revisit after GA — K16).
- Byte-identical CLI human text vs MCP
content[]formatters.
Key Decisions¶
| # | Decision | Rationale |
|---|---|---|
| K1 | Catalog-first tool definitions in TypeScript under shared/src/catalog/ as the SSOT for name/description/schema/action/formatters | Regex scraping of tool files is fragile; Zod schemas already live in shared; catalog imports them by symbol |
| K2 | CLI is a second frontend over createProxyClient | Reuses token class selection, env vars, 30s timeout, response validation; no dual auth |
| K3 | MCP remains primary agent interface; CLI for humans/scripts/CI | Complements MCP; does not fork agent skill workflows |
| K4 | Binary name wslite (primary), package @workspace-lite/cli; optional dual bin workspace-lite | Avoids wsl (Windows Subsystem for Linux) and gws (existing npm package / other tooling) |
| K5 | Parity = semantic equivalence, not byte-identical UX | Flags/tables vs MCP content[] expected; same action/params/errors/confirm |
| K6 | Per-service port + MCP flip + delete old tools in one PR | Eliminates long dual-write; each service becomes catalog SSOT when merged; no global dual-registration flag |
| K7 | Confirm UX: interactive prompt or --yes only; never auto-generate --confirm | Schemas include confirm; CLI owns approval UX; MCP agents still pass confirm in JSON |
| K8 | Exit codes: 0 success, 1 hard fail, 2 confirm, 3 partial (incl. all-ops-failed batch), 4 usage | Scriptable automation; all-failed batch still success: true + partial from proxy → exit 3 |
| K9 | Do not invent CLI-only tools that skip the proxy | Every command maps to a catalog tool → proxy action |
| K10 | Generic escape hatch: wslite call with strict schema rules | --tool when ambiguous; refuse unknown actions unless gated --raw; always run confirm resolution |
| K11 | Client risk SSOT is only catalog/risk.ts (maps + dynamic rules). ToolSpec does not store hand-authored riskClass; docs/help derive via staticRiskClass(service, action) | Prevents triple maintenance vs Policy.gs / token maps |
| K12 | Honest security model in docs is a GA dependency | Document Auth.gs full default classes; fix architecture/ops security docs + AGENTS.md |
| K13 | CLI framework: Commander | Mature subcommands/help; lock for PR 4 implementers |
| K14 | Batch confirm injects into each gated op’s params.confirm | Matches Policy.gs handleFn(op.action, op.params) — outer batch confirm is ignored |
| K15 | Shared execute path for confirm + parse used by CLI; MCP uses extended registerTool | Invoke-parity tests mock ProxyClient for both paths |
| K16 | Monorepo-local distribution only for now; do not publish @workspace-lite/shared or @workspace-lite/cli to public npm before CLI GA | Avoids private-package publish chicken-and-egg; revisit public npm after CLI GA (final) |
| K17 | Config: process env only in v1 (optional dotenv load of repo .env); no ~/.config token file | Matches MCP; fewer secret-on-disk paths |
| K18 | Multi-action tools declare static actions[] plus runtime resolveAction | Enables wslite call, validators, and mappings without probing resolveAction; replaces drive_list_folders special case |
| K19 | Typecheck paths for shared/src/catalog/** are required in PR 2 | Current @workspace-lite/shared/* → shared/src/*.ts cannot resolve nested catalog modules |
| K20 | Interactive batch confirm UI: summary only — op index, action, risk class; no full params | Avoids leaking PII (recipients, bodies, file content) into the terminal prompt |
| K21 | --raw on wslite call is gated by WSLITE_ALLOW_RAW=1 | Useful for debugging uncatalogued proxy actions; not advertised in default help; still runs confirm resolution |
Definition of MCP ↔ CLI Parity¶
What parity requires¶
| Dimension | MCP | CLI | Parity rule |
|---|---|---|---|
| Tool identity | drive_get_file | wslite drive get-file | 1:1 via mcpNameToCliPath; MCP name in help |
| Proxy action | callProxy(action, args) | Same | Identical action string after resolveAction |
| Parameters | Zod schema on tool | Same schema after parseCliArgs coercion | Same required/optional fields, defaults, refine rules |
| Auth | Env tokens via createProxyClient | Same env vars | Same token class selection |
| Confirmation (single) | Agent passes confirm: true | Prompt / --yes sets top-level confirm: true | Server Policy enforces |
| Confirmation (batch) | Agent sets each op params.confirm | CLI injects into each gated op params | Outer batch confirm ignored by server |
| Idempotency | idempotencyKey in schema | --idempotency-key flag | Same field name in proxy params |
| Errors | codes / messages / correlationId | stderr + exit + optional JSON | Same error.code / message / correlationId |
| Batch | Per-op results, partial: true | Same envelope; exit 3 if partial | Same sequential semantics |
| Dynamic risk | risk.ts via proxy-client | Same resolveRiskClass | Shared implementation |
| Coverage (end state) | 218 tools | 218 tools | Catalog length === sum(registry.toolCount) |
What parity does not require¶
- Identical human-readable text (MCP custom formatters vs CLI tables).
- MCP-style
content: [{ type: 'text', text }]on stdout. - Flag sugar for every nested batch field (batch uses
--operations-file/--params-json). - CLI human snapshots matching MCP text (goldens use
--jsonprimarily).
Parity test oracle (non-tautological)¶
1. Registration: CaptureServer(registerCatalogTools(service)) names/schemas
match catalog module for that service; count === registry.toolCount
2. Invoke matrix (fixture table, mock ProxyClient):
For each fixture { tool, rawCliArgs | mcpArgs }:
CLI: parseCliArgs → applyConfirmPolicy → callProxy(action, params)
MCP: handler(args) → callProxy(action, params)
Assert identical (service, action, params) including batch per-op confirm
3. External oracles (survive cutover):
catalog.length === sum(service-registry toolCount)
every tool name appears in docs/services/*.md and skill tool-catalog
risk.ts static class vs ACTION_POLICIES (validator)
4. Do NOT require CLI human formatter text === MCP content[] text
Proposed Design¶
High-level architecture¶
flowchart TB
subgraph frontends [Frontends]
MCP[MCP servers stdio]
CLI[wslite CLI]
end
subgraph ssot [Single source of truth]
CAT[Tool Catalog<br/>shared/src/catalog/services]
SCH[Zod schemas<br/>shared/src/schemas.ts]
RISK[risk.ts<br/>static maps + dynamic]
EXEC[Shared confirm + parse helpers]
end
subgraph client [Shared client]
PC[createProxyClient]
end
subgraph remote [Remote]
AS[Apps Script proxies]
GAPI[Google Workspace APIs]
end
SCH --> CAT
CAT --> MCP
CAT --> CLI
RISK --> PC
RISK --> EXEC
EXEC --> CLI
MCP --> PC
CLI --> PC
PC --> AS
AS --> GAPI Package placement¶
workspace-lite/
├── shared/
│ └── src/
│ ├── catalog/
│ │ ├── types.ts
│ │ ├── risk.ts # ONLY client static/dynamic risk SSOT
│ │ ├── register-from-catalog.ts
│ │ ├── parse-cli-args.ts # coercion + Zod (shared, used by CLI)
│ │ ├── confirm-policy.ts # single + batch confirm injection
│ │ ├── naming.ts # mcpNameToCliPath
│ │ ├── services/
│ │ │ ├── drive.ts # after drive flip PR
│ │ │ ├── gmail.ts
│ │ │ └── ...
│ │ └── index.ts
│ ├── proxy-client.ts # imports risk.ts; optional diagnostics hook
│ ├── schemas.ts
│ ├── response.ts
│ └── tool-helpers.ts # extended ToolDef / registerTool
├── packages/
│ ├── <service>/src/
│ │ ├── index.ts # registerCatalogTools after flip
│ │ ├── proxy.ts # keep thin re-export OR delete after flip
│ │ └── tools/ # DELETED in same PR as flip
│ └── cli/
│ ├── package.json # bin: wslite, workspace-lite
│ └── src/
│ ├── index.ts
│ ├── program.ts # Commander, built from catalog
│ ├── execute.ts # uses confirm-policy + createProxyClient
│ ├── render.ts
│ ├── exit-codes.ts
│ └── commands/{call,list-tools,doctor}.ts
├── config/service-registry.json
├── scripts/
│ ├── validate-architecture.mjs # risk path tokens updated in risk-extract PR
│ └── registry-helpers.mjs # toolActionMappings from catalog post-flip
└── tests/
├── tool-registration.test.ts
├── catalog-parity.test.ts
├── invoke-parity.test.ts # CLI vs MCP callProxy matrix
├── risk-resolution.test.ts
└── cli/*.test.ts # included by updated test glob
Catalog schema (TypeScript types)¶
// shared/src/catalog/types.ts
import type { ZodTypeAny } from 'zod'
import type { ProxyResponse } from '../response.js'
import type { TokenClass } from './risk.js' // client routing class only
export type ServiceKey =
| 'drive' | 'gmail' | 'calendar' | 'sheets'
| 'slides' | 'docs' | 'tasks' | 'forms'
/**
* How the tool maps Zod fields to CLI flags.
* Default: kebab-case of schema keys; booleans as --flag / --no-flag.
* Note: schema key `confirm` is NEVER emitted as a CLI flag (see Confirm rules).
*/
export interface CliFlagHints {
/** Override flag name for a schema key, e.g. { pageSize: 'page-size' } */
aliases?: Record<string, string>
/** Keys accepted as positional args in order, e.g. ['fileId'] */
positionals?: string[]
/** Prefer reading this key from a file path flag (e.g. content from --content-file) */
fileInputs?: Record<string, string>
/**
* Keys that must be supplied as JSON (string or @file) even in sugared mode.
* Default auto-detection: z.array, z.object, z.union/discriminatedUnion → json keys.
*/
jsonKeys?: string[]
/** Hide from generated help; still available via --params-json */
advancedKeys?: string[]
/**
* v1 params-json-only tool: do not generate per-field flags (batch tools default true).
*/
paramsJsonOnly?: boolean
}
export type OutputKind =
| 'json-default'
| 'list'
| 'text'
| 'permissions'
| 'raw-proxy'
export interface ToolFormatter {
kind: OutputKind
/** MCP content[] formatter — MUST receive args (not {}) */
formatMcp?: (
result: ProxyResponse,
args: Record<string, unknown>,
) => { content: { type: 'text'; text: string }[] }
/** Optional CLI human formatter; goldens prefer --json */
formatCli?: (result: ProxyResponse, args: Record<string, unknown>) => string
listOptions?: {
itemsKey: string
noun: string
itemSummary: (item: unknown) => string
}
summary?: string
hint?: string
}
export interface ToolSpec {
/** Canonical MCP tool name, e.g. drive_get_file */
name: string
service: ServiceKey
/** Default Apps Script action, e.g. fileGet */
action: string
description: string
/** Zod raw shape Record (same as today in schemas.ts) */
schema: Record<string, ZodTypeAny>
/**
* DO NOT hand-author static risk here.
* Use staticRiskClass(service, action) / resolveRiskClass(service, action, params)
* from risk.ts for docs, help, and confirm.
*/
/** Extra client-side validation after Zod (e.g. calendar date order) */
validate?: (args: Record<string, unknown>) => void
/** True if action appears in BATCH_ACTIONS for the service */
batchEligible: boolean
/** This tool is the service batch entrypoint (action === 'batch') */
isBatchTool?: boolean
/**
* Multi-action tools (e.g. drive_list_folders → folderList | folderListRoot).
* Runtime selection of which proxy action to call.
* MUST be paired with `actions` when more than one action is possible.
*/
resolveAction?: (args: Record<string, unknown>) => string
/**
* Static enumerable set of all proxy actions this tool may invoke.
* Required when `resolveAction` is set (or whenever action is not 1:1 with the tool).
* - Default for ordinary tools: omit; treated as `[action]`.
* - Multi-action example (drive_list_folders):
* action: 'folderList' // default / primary for docs
* actions: ['folderList', 'folderListRoot']
* resolveAction: (args) => args.folderId ? 'folderList' : 'folderListRoot'
*
* Consumers (must not probe resolveAction):
* - `wslite call <service> <action>` membership: tool matches if action is in
* effectiveActions(tool) === tool.actions ?? [tool.action]
* - `toolActionMappings` / architecture validators / batch docs: emit one
* mapping row per entry in effectiveActions(tool) (replaces today's
* drive_list_folders special case in registry-helpers.mjs)
* - CI: if resolveAction is defined, actions must be present, non-empty,
* include tool.action, and resolveAction return values in fixtures must
* be ⊆ actions
*/
actions?: string[]
formatter?: ToolFormatter
cli?: CliFlagHints
/** Help group; default 'general' when omitted */
group?: 'list' | 'read' | 'write' | 'manage' | 'settings' | 'batch' | 'advanced' | 'general'
}
/** Effective static action list for mappings and wslite call (never probe resolveAction). */
export function effectiveActions(tool: ToolSpec): string[] {
return tool.actions?.length ? tool.actions : [tool.action]
}
export interface Catalog {
version: 1
tools: ToolSpec[]
byName: Map<string, ToolSpec>
byService: Map<ServiceKey, ToolSpec[]>
}
Risk module — sole client static/dynamic SSOT¶
// shared/src/catalog/risk.ts
/** Client token routing classes — matches proxy-client today. Auth "draft" is NOT a routing class. */
export type TokenClass =
| 'read'
| 'write'
| 'send'
| 'share'
| 'destructive'
| 'admin'
/** Auth.gs capability classes include draft; used only when documenting Script Properties. */
export type AuthCapabilityClass = TokenClass | 'draft'
/**
* Static map: only non-read actions (omitted ⇒ read), ported from ACTION_TOKEN_CLASSES.
* This is the ONLY hand-maintained client risk table.
*/
export const ACTION_TOKEN_CLASSES: Record<string, Record<string, TokenClass>>
export function staticRiskClass(service: string, action: string): TokenClass
export function resolveRiskClass(
service: string,
action: string,
params?: Record<string, unknown>,
): TokenClass
export function batchRiskClass(
service: string,
params?: Record<string, unknown>,
): TokenClass
/** Policy.gs POLICY_CONFIRMATION_CLASSES_ — send/share/destructive (not admin, not write). */
export function isConfirmClass(tokenClass: TokenClass): boolean {
return tokenClass === 'send' || tokenClass === 'share' || tokenClass === 'destructive'
}
Notes:
createProxyClientimportsresolveRiskClass/batchRiskClassfromrisk.ts(behavior-preserving move fromproxy-client.ts).adminis a token selection class for mixed batch high-risk ops — it is not a confirm class. Confirm still walks each nested op.- Architecture validator compares
risk.tsstatic entries (+ known dynamic exceptions) to Apps ScriptACTION_POLICIES.class, not a third field onToolSpec. - Help/docs show risk via
staticRiskClass(tool.service, tool.action)orresolveRiskClasswhen sample params exist.
Auth draft: exists only as a token capability in Auth.gs (allowDraftToken policies). Client never routes to a PROXY_DRAFT_TOKEN env in current code; do not add draft to TokenClass.
Confirm policy module (single + batch)¶
// shared/src/catalog/confirm-policy.ts
export type ConfirmDecision =
| { ok: true; args: Record<string, unknown> }
| { ok: false; reason: 'refused' | 'required_non_tty'; message: string }
/**
* Apply client-side confirmation before callProxy.
* - Single tools: if resolveRiskClass is confirm-gated and args.confirm !== true,
* prompt or --yes sets args.confirm = true.
* - Batch tools (action === 'batch' or tool.isBatchTool):
* 1. Walk args.operations[]
* 2. For each op, risk = resolveRiskClass(service, op.action, op.params)
* 3. Collect ops where isConfirmClass(risk) && op.params?.confirm !== true
* 4. If any: one summary prompt (or --yes). Prompt content is SUMMARY ONLY:
* per gated op: index, action name, resolveRiskClass result — NOT full params
* (PII: email bodies, recipients, file content must not appear in the prompt).
* 5. On accept: set confirm:true on EACH gated op's params (clone params objects)
* 6. Do NOT rely on outer args.confirm — Policy.gs ignores it for nested handle()
* - Outer batch token class (incl. admin) is irrelevant for confirm injection.
*/
export async function applyConfirmPolicy(opts: {
service: ServiceKey
action: string
args: Record<string, unknown>
isBatch: boolean
yes: boolean
tty: boolean
prompt: (summary: string) => Promise<boolean>
}): Promise<ConfirmDecision>
Zod → CLI parsing (coercion without forked schemas)¶
MCP receives already-typed JSON from the SDK. CLI receives strings from argv. Do not maintain parallel z.coerce schema copies.
// shared/src/catalog/parse-cli-args.ts
/**
* parseCliArgs(shape, rawFlags) → unknown then z.object(shape).strict().parse(...)
*
* Coercion pipeline on raw flag values BEFORE Zod:
* 1. Drop / ignore reserved keys handled globally: never accept `confirm` from flags
* (strip if present; only applyConfirmPolicy may set it).
* 2. For each shape key present in raw:
* a. If value is string and looks like JSON object/array (starts with { or [)
* OR key is in jsonKeys / detected complex → JSON.parse
* b. Else if Zod type is number-like (unwrap optional/default) → Number(value);
* reject NaN with BAD_REQUEST-style CLI error (exit 4)
* c. Else if boolean-like → accept true/false/1/0/yes/no (case-insensitive);
* bare --flag ⇒ true for boolean optional
* d. Else keep string
* 3. Apply fileInputs: read file contents into target keys
* 4. Merge --params-json / --params-json @file as base object (flags override)
* 5. z.object(shape).strict().parse(coerced) — same shape MCP registers
*
* Complex detection: walk Zod type def; arrays, objects, unions, records ⇒ jsonKeys.
*
* v1 params-json-only tools (default for isBatchTool, and any tool with
* cli.paramsJsonOnly): only --params-json / --operations-file; no per-field flags.
* operations-file is sugar for { operations: <file json> } on batch tools.
*/
export function parseCliArgs(
shape: Record<string, ZodTypeAny>,
raw: Record<string, unknown>,
hints?: CliFlagHints,
): Record<string, unknown>
Shared with MCP: MCP does not use parseCliArgs; it uses SDK-validated args. Both run the same tool.validate?.(args) and same callProxy. Invoke-parity fixtures supply already-typed objects for MCP and stringly flags for CLI, asserting post-parse params match.
MCP registration from catalog (complete algorithm)¶
Extend ToolDef / registerTool in shared/src/tool-helpers.ts:
export interface ToolDef {
name: string
description: string
schema: Record<string, ZodTypeAny>
action: string
resolveAction?: (args: Record<string, unknown>) => string
validate?: (args: Record<string, unknown>) => void
summary?: string
hint?: string
/** Receives args — required for formatters that close over inputs */
format?: (
result: ProxyResponse,
args: Record<string, unknown>,
) => { content: { type: 'text'; text: string }[] }
}
export function registerTool(server: ToolServer, client: ProxyClient, def: ToolDef) {
server.registerTool(
def.name,
{ description: def.description, inputSchema: def.schema },
async (args: Record<string, unknown>) => {
try {
def.validate?.(args)
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid tool input'
return formatResponse({ success: false, error: { code: 'BAD_REQUEST', message } })
}
const action = def.resolveAction?.(args) ?? def.action
try {
const result = await client.callProxy(action, args)
if (def.format) return def.format(result, args)
return formatResponse(result, { summary: def.summary, hint: def.hint })
} catch (error) {
const message = error instanceof Error ? error.message : 'Proxy call failed'
return formatResponse({ success: false, error: { code: 'PROXY_ERROR', message } })
}
},
)
}
export function registerCatalogTools(
server: ToolServer,
client: ProxyClient,
tools: ToolSpec[],
): void {
for (const tool of tools) {
registerTool(server, client, {
name: tool.name,
description: tool.description,
schema: tool.schema,
action: tool.action,
resolveAction: tool.resolveAction,
validate: tool.validate,
summary: tool.formatter?.summary,
hint: tool.formatter?.hint,
format: tool.formatter?.formatMcp
? (result, args) => tool.formatter!.formatMcp!(result, args)
: undefined,
})
}
}
Parity / mapping tests for multi-action tools:
drive_list_folderscatalog entry must declareactions: ['folderList', 'folderListRoot']andresolveActionas today (folderIdpresent →folderList, elsefolderListRoot).- Mock client must see the resolved action at invoke time.
- Catalog-derived
toolActionMappingsmust emit both action rows (noregistry-helpers.mjsstring special-case after drive flip). wslite call drive folderListRootmatches viaeffectiveActions(tool)without probingresolveAction.
Service index.ts after flip:
// packages/tasks/src/index.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { registerCatalogTools } from '@workspace-lite/shared/catalog'
import { tasksTools } from '@workspace-lite/shared/catalog/services/tasks'
import { createProxyClient } from '@workspace-lite/shared/proxy-client'
const server = new McpServer({ name: 'google-workspace-tasks', version: '1.0.0' })
registerCatalogTools(server, createProxyClient('tasks'), tasksTools)
// connect transport…
packages/<service>/src/proxy.ts: after flip, either delete and import createProxyClient only from shared, or keep as one-line re-export for any residual imports. Prefer delete when tools/ is deleted.
CLI command model¶
Framework¶
Commander (K13). Program is built by iterating catalog tools present in the build (grows as services flip).
Binary and global flags¶
wslite [--json] [--yes|-y] [--quiet|-q] [--verbose|-v]
[--idempotency-key <key>]
[--params-json <json|@file>] # merge into tool args (flags win)
<command>
| Flag | Behavior |
|---|---|
--json | stdout machine JSON only |
--yes / -y | Non-interactive approval: applyConfirmPolicy injects confirm: true (top-level and/or per gated batch ops) |
--quiet | Suppress non-error human chrome |
--verbose | Log service, action, resolved risk class(es); v1 does not log token env name unless diagnostics API added (see Observability) |
confirm is not a user flag. Auto-flag generation excludes schema key confirm. If a user passes --confirm anyway, Commander should not define it; stray args error (exit 4). Only --yes / interactive prompt may set confirm: true. MCP agents continue to pass confirm in tool JSON.
Naming algorithm¶
// shared/src/catalog/naming.ts
/** drive_get_file → { service: 'drive', path: ['get-file'], mcpName: 'drive_get_file' } */
export function mcpNameToCliPath(name: string): {
service: ServiceKey
path: string[] // kebab-case segments after service prefix
mcpName: string
} {
const service = name.split('_')[0] as ServiceKey
const rest = name.slice(service.length + 1) // after "drive_"
const path = rest.split('_').map((s) => s.replace(/_/g, '-'))
// rest is already snake pieces: get_file → ["get", "file"] → join as single
// command name: kebab of full rest: get_file → "get-file"
return {
service,
path: [rest.split('_').join('-')], // one subcommand segment: get-file
mcpName: name,
}
}
// Command: wslite <service> <path[0]> e.g. wslite drive get-file
// Alias: wslite run <mcpName> e.g. wslite run drive_get_file
- Empty schema tools (
gmail_profile,calendar_list_calendars): still one subcommand; no required flags. - Collision check at catalog build: assert unique
(service, path[0])for all tools; fail CI if not. - Golden-test: map all catalog names once catalog exists.
- Default
groupwhen omitted:'general'.
Command tree¶
wslite
├── doctor
├── tools [--service] [--risk] # risk from staticRiskClass
├── run <mcpToolName> # alias for sugared path
├── call <service> <action> # see strict rules below
├── drive | gmail | … # only services present in catalog build
│ └── <kebab-rest>
└── help
wslite call strict rules¶
- Resolve catalog tools where
tool.service === serviceandeffectiveActions(tool).includes(action)
(effectiveActions=tool.actions ?? [tool.action]).
Do not call or proberesolveActionfor matching — only the static list. - If zero tools match:
- Default: refuse with exit 4 (“unknown action; no catalog tool maps to this action”).
--rawescape hatch (K21): only honored whenprocess.env.WSLITE_ALLOW_RAW === '1'.- If
--rawwithout the env var: exit 4 with a short message that raw mode requiresWSLITE_ALLOW_RAW=1(do not print long docs in the error). - If env set and
--raw: POST without Zod (still runsapplyConfirmPolicyusingresolveRiskClass(service, action, params)). - Default help (
wslite call --help, top-level help): do not advertise--raworWSLITE_ALLOW_RAW. Document only indocs/getting-started/cli.md(debug / operator section) and optionalWSLITE_DEBUGnotes.
- If
- If exactly one tool matches → use its schema (batch tool if action is
batch).
At execute time, still runresolveAction?.(args) ?? actionso multi-action tools pick the correct proxy action from params (e.g.folderListvsfolderListRoot). If the user passed an action string viawslite callthat differs fromresolveAction(args), prefer the resolved action from args after Zod parse (params decide); the CLI action argument is for tool lookup and risk preview only. Document this in--helpfor multi-action tools. - If multiple tools match → require
--tool <mcpName>; else exit 4. - Prefer explicit
--toolalways when scripting.
Confirm flow (sequence)¶
sequenceDiagram
participant U as User
participant CLI as wslite
participant CP as applyConfirmPolicy
participant PC as createProxyClient
participant AS as Apps Script Policy
U->>CLI: wslite drive batch --operations-file ops.json --yes
CLI->>CLI: parseCliArgs / operations-file
CLI->>CP: isBatch=true, yes=true
CP->>CP: walk ops; resolveRiskClass each
CP->>U: summary prompt (index, action, risk only)
CP->>CP: set params.confirm=true on each gated op
CLI->>PC: callProxy(batch, args)
PC->>AS: POST
AS->>AS: handle(op.action, op.params) per op
AS-->>CLI: partial or success results Single-tool path sets top-level args.confirm = true only.
Exit code matrix¶
| Condition | Exit |
|---|---|
success: true and not partial | 0 |
success: true and partial: true (including data.status === 'FAILED' all ops failed) | 3 |
success: false and error.code === 'CONFIRMATION_REQUIRED' | 2 |
success: false other codes | 1 |
| Client threw (missing env, network, malformed JSON, timeout) | 1 (catch in executeTool; stderr message) |
| User refused prompt | 2 |
Non-TTY without --yes when confirm needed | 2 |
| Usage / unknown tool / Zod CLI parse failure / ambiguous call | 4 |
Output contracts¶
--json success / failure — same envelope as prior design (tool, service, action, data/error, partial, results). Prefer this for golden tests.
Human mode uses formatCli or JSON pretty-print of data; do not require matching MCP formatter text.
Execution core¶
// packages/cli/src/execute.ts
export async function executeTool(
tool: ToolSpec,
rawArgs: Record<string, unknown>,
opts: { yes: boolean; json: boolean; tty: boolean },
): Promise<{ response?: ProxyResponse; exitCode: number; errorMessage?: string }> {
try {
const parsed = parseCliArgs(tool.schema, rawArgs, tool.cli)
tool.validate?.(parsed)
const action = tool.resolveAction?.(parsed) ?? tool.action
const isBatch = tool.isBatchTool === true || action === 'batch'
const confirmed = await applyConfirmPolicy({
service: tool.service,
action,
args: parsed,
isBatch,
yes: opts.yes,
tty: opts.tty,
prompt: interactivePrompt,
})
if (!confirmed.ok) {
return { exitCode: 2, errorMessage: confirmed.message }
}
const client = createProxyClient(tool.service)
const response = await client.callProxy(action, confirmed.args)
return { response, exitCode: exitCodeFor(response) }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { exitCode: 1, errorMessage: message }
}
}
Migration Path (vertical slices — no long dual-write)¶
Principle (replaces vague adapter→invert + multi-PR dual path)¶
For each service S:
- Author
shared/src/catalog/services/S.tsby porting current tools (mechanical copy of name/description/schema/action/formatters). - Point
packages/S/src/index.tsatregisterCatalogTools. - Delete
packages/S/src/tools/**(or leave only if something else imports — should not). - Update
toolActionMappings/ architecture validation for S to read catalog (hybrid registry helper: catalog if present else scrape — only during mixed monorepo; remove scrape when all services flipped). Catalog path: for each tool emit one mapping pereffectiveActions(tool)entry (covers multi-action tools without hard-coded service special cases). - Parity tests for S must pass in the same PR.
No feature flag. Unflipped services keep old files; flipped services are catalog-only. CLI lists only tools in catalog modules that exist and are exported from catalog/index.ts.
Phase 0 — Inventory (optional PR or part of PR 1)¶
- Snapshot tool list from CaptureServer for reference during ports.
- List custom formatters and multi-action tools.
Phase 1 — risk.ts extract + confirm-policy + parse-cli-args (no MCP flip)¶
- Move maps/dynamic rules; update
validate-architecture.mjsstring paths for risk module. - Unit tests for dynamic elevation and batch admin token class.
- Land types, naming, confirm-policy, parse-cli-args with unit tests.
Phase 2 — CLI package MVP against whatever catalog exists¶
- After first service flip (tasks), CLI can exercise real tools.
- CLI MVP acceptance: doctor, tools list, call, execute/confirm/exit codes, tasks sugared commands — not full 218.
Phase 3 — Remaining services as vertical slice PRs¶
Order (small/clean → complex):
- tasks (13)
- forms (16)
- calendar (22)
- slides (25)
- docs (26)
- sheets (33)
- gmail (39)
- drive (44) — last; multi-action + most custom formatters
Phase 4 — Cleanup¶
- Remove hybrid
toolActionMappingsscrape path. - Optional generate skill tool-catalog from catalog.
- Security/docs honesty PR (required for CLI GA messaging).
Rollback¶
- Revert one service PR restores that package’s
tools/and index registration. - CLI continues with remaining catalog services.
API / Interface Changes¶
New exports (shared/package.json) — required¶
{
"exports": {
".": "./dist/index.js",
"./schemas": "./dist/schemas.js",
"./response": "./dist/response.js",
"./proxy-client": "./dist/proxy-client.js",
"./tool-helpers": "./dist/tool-helpers.js",
"./catalog": "./dist/catalog/index.js",
"./catalog/*": "./dist/catalog/*"
}
}
Runtime resolution after build:shared uses these exports. They do not fix monorepo typecheck path mapping (see below).
Typecheck path mapping — required in PR 2 (not optional)¶
Current tsconfig.typecheck.json only has:
The single-segment pattern maps @workspace-lite/shared/catalog → shared/src/catalog.ts (a file), not shared/src/catalog/index.ts, and cannot resolve @workspace-lite/shared/catalog/services/tasks. Nested catalog imports will fail npm run typecheck until paths are updated.
PR 2 acceptance criteria — mandate these path entries (order matters; more specific first):
{
"compilerOptions": {
"paths": {
"@workspace-lite/shared": ["shared/src/index.ts"],
"@workspace-lite/shared/catalog": ["shared/src/catalog/index.ts"],
"@workspace-lite/shared/catalog/*": ["shared/src/catalog/*"],
"@workspace-lite/shared/*": ["shared/src/*.ts"]
}
}
}
Also ensure shared/tsconfig.json (package build) emits dist/catalog/** when those sources exist. Build order remains build:shared before packages.
Do not treat path mapping as “verify if needed” — it is a hard requirement for any import of @workspace-lite/shared/catalog or @workspace-lite/shared/catalog/services/* from packages or tests under tsconfig.typecheck.json.
Extended APIs¶
| API | Change |
|---|---|
ToolDef / registerTool | resolveAction, format(result, args), hint |
ToolSpec | optional actions?: string[] + effectiveActions() for multi-action tools |
createProxyClient | uses risk.ts; optional later: diagnostics callback |
Service index.ts | catalog registration after flip |
| Root scripts | test/lint/build include cli (see Testing) |
tsconfig.typecheck.json | required catalog path map (PR 2) |
shared/package.json exports | ./catalog, ./catalog/* (PR 2) |
Unchanged¶
- Apps Script
{ token, action, params }contract - Env var names
- MCP bin names
google-workspace-*
Data Model Changes¶
No Apps Script Script Properties changes for CLI v1. Proxy envelope unchanged.
Catalog source of truth = TypeScript modules. Optional generated dist/catalog.json for docs — not hand-edited.
CLI UX for 218 Tools¶
Discoverability¶
wslite tools
wslite tools --service gmail --risk destructive # filter via staticRiskClass
wslite gmail --help
wslite gmail send --help # flags from Zod; no --confirm listed
wslite run gmail_send --help
Batch UX¶
wslite drive batch --operations-file ./ops.json --yes
# applyConfirmPolicy sets params.confirm on each gated op inside ops.json copy
Large / complex tools¶
- Batch + multi-array writes:
paramsJsonOnly/--params-json/--operations-file. fileInputsfor large strings.
Shell completion¶
Optional after full catalog; generate from mcpNameToCliPath.
Security & Privacy Considerations¶
Threat model (CLI-specific)¶
| Threat | Severity | Mitigation |
|---|---|---|
| CLI weakens confirm gates | High | Policy.gs authoritative; batch injects per-op confirm |
Outer --yes without per-op inject | High | applyConfirmPolicy batch walk (Issue 1 fix) |
| Tokens in logs | High | Never print token values; v1 verbose skips env name until safe API |
| Scripts auto-confirm send | High | Document --yes danger |
| Primary token full classes | Medium | Honest docs (GA dependency) |
--raw call bypasses Zod | Medium | Requires WSLITE_ALLOW_RAW=1; omitted from default help; still confirm-resolved; docs only in operator/debug section |
Auth model¶
Unchanged path through createProxyClient / selectProxyToken:
- non-read:
[CLASS, ADMIN, PRIMARY] - read:
[READ, PRIMARY, ADMIN]
Security documentation (required for CLI GA — PR docs)¶
- State code default
DEFAULT_AUTH_TOKEN_CLASSES = 'read,draft,write,destructive,share,send'. - Fix
docs/architecture/security.mdanddocs/operations/security.md(read,draftclaim). - Fix
AGENTS.mdto state actual default string before progressive examples. - Add error codes:
CONFIRMATION_REQUIRED,ACTION_NOT_ALLOWED,BATCH_ACTION_NOT_ALLOWED. - CLI
--yes≡ agent passingconfirm: true, not a policy bypass.
Observability¶
| Stream | Content |
|---|---|
| stdout | Result only (human or --json) |
| stderr | Prompts, verbose diagnostics, errors when not --json |
Doctor (v1): presence checks for GOOGLE_WORKSPACE_<SERVICE>_PROXY_URL and …_PROXY_TOKEN (primary). Optionally list whether class-scoped env vars are set (yes/no) without claiming which would be selected. Full “env name that would be used” requires extending createProxyClient to return diagnostics — deferred; do not pretend verbose has it in v1.
Optional later: createProxyClient accepts onTokenSelected?: ({ tokenClass, envName }) => void (values never passed).
Testing Strategy¶
Root script updates (required in CLI / first vertical PRs)¶
{
"test": "tsx --test tests/**/*.test.ts tests/**/*.test.mts",
"build:packages": "tsc -b packages/drive … packages/cli",
"lint": "eslint shared/src packages/*/src tests scripts --ext .ts,.mjs"
}
Use a glob that includes tests/cli/**/*.test.ts (current tests/*.test.ts does not). Prefer:
Include packages/cli in build:packages and ensure tsconfig.typecheck.json include already covers packages/*/src/**/*.ts and tests/**/*.ts.
Test layers¶
- Registration — CaptureServer +
registerCatalogToolsper flipped service; count vs registry. - Risk — dynamic/batch fixtures; architecture validator vs ACTION_POLICIES.
- parseCliArgs — numbers, bools, JSON arrays, defaults, reject
confirmflag injection from raw. - applyConfirmPolicy — single tool; batch per-op inject; admin batch still injects nested confirms; non-TTY.
- Invoke parity — fixture matrix CLI vs MCP handlers → identical
callProxyargs (mock client). - CLI goldens — primarily
--jsonsnapshots; human formatters optional/non-blocking. - Multi-action —
drive_list_foldershasactions[]+resolveAction; mappings enumerate both;wslite callmatches viaeffectiveActions. - External oracles — docs + skill names; catalog length === 218 when complete.
After cutover, registration tests are not tautological if they still assert schema exports, description length, prefix rules, and registry counts — plus invoke-parity remains the semantic check.
Rollout Plan¶
- risk extract + shared parse/confirm (behavior-preserving for MCP).
- tasks vertical slice + CLI MVP (tasks-only surface).
- forms → calendar → slides → docs → sheets → gmail → drive slices.
- Docs/security honesty + CLI guide (required before advertising GA).
- Optional: generate skill catalog; doctor in installer.
No global WORKSPACE_LITE_CATALOG_MCP flag — cutover is per-package source change.
Rollback = revert service PR; uninstall CLI independently.
Distribution¶
| Item | Choice |
|---|---|
| Path | packages/cli |
| Package | @workspace-lite/cli (private) |
| Bins | wslite, optional workspace-lite |
| Node | >=20 |
| Install v1 | monorepo node_modules/.bin/wslite after build |
| Publish | Monorepo-local only until after CLI GA (K16); then reconsider public npm for @workspace-lite/cli (+ shared if needed) |
| Framework | Commander |
| Deps | commander, @workspace-lite/shared, zod |
Docs & Skill Impact¶
| Artifact | Change |
|---|---|
README.md | CLI section when MVP ships |
docs/getting-started/cli.md | Install, env, --yes, batch per-op confirm (summary-only prompt), exit codes; debug subsection for WSLITE_ALLOW_RAW=1 / --raw (not in default CLI help) |
docs/architecture/overview.md | CLI frontend diagram |
docs/architecture/security.md | Required: fix default token classes |
docs/operations/security.md | Required: same + --yes |
docs/api/error-codes.md | Add CONFIRMATION_REQUIRED, ACTION_NOT_ALLOWED, BATCH_ACTION_NOT_ALLOWED |
AGENTS.md | State Auth.gs default full classes; catalog path; wslite |
skills/google-workspace/* | Operator debug note; rules: CLI --yes ≡ explicit approval |
| Installer skill | Optional later doctor only |
Alternatives Considered¶
A — Thin CLI wrapping MCP stdio¶
Rejected: spawn cost, mapping still needed, poor scripting UX.
B — Hand-maintained Commander commands (no catalog)¶
Rejected: drift; violates SSOT.
C — Generate CLI from OpenAPI of Apps Script¶
Rejected: no OpenAPI; duplicates Zod.
D — Catalog in JSON only¶
Rejected as sole SSOT; optional generated artifact OK.
E — Single multi-service MCP + CLI process¶
Deferred; MCP stays 8 bins.
F — Runtime adapter: CaptureServer + schema map drives CLI without catalog port¶
Description: Treat current register*Tools(CaptureServer) output as temporary SSOT: CLI looks up name → schema/action by scraping registration + existing toolActionMappings / heuristics, without writing catalog/services/*.ts first.
| Pros | Cons |
|---|---|
| CLI can expose all 218 tools before ports finish | Formatters/validate/resolveAction not in CaptureServer metadata |
| Avoids dual-write during migration | Regex/action mapping remains fragile (today’s validator pain) |
| Fast demo | Does not deliver MCP SSOT; second system still lives in packages/tools |
Decision: Reject as end-state; optional short-lived spike only. End-state remains catalog modules. Vertical slice port+flip is the migration (not a permanent adapter). If a spike uses CaptureServer for CLI prototyping, it must not ship as GA architecture.
Risks¶
| Risk | Severity | Mitigation |
|---|---|---|
| Batch confirm wrong injection | Critical | applyConfirmPolicy per-op; tests |
| Risk map vs Policy drift | High | Validator risk.ts ↔ ACTION_POLICIES |
| Port misses custom formatter | Medium | Invoke + MCP golden per service PR |
| Zod coercion edge cases | Medium | parseCliArgs tests; params-json-only for hard tools |
| PR too large (drive) | Medium | Drive alone as final slice; gmail separate |
| Validator path break on risk move | Medium | Update validate-architecture in same PR as extract |
| Scope creep to setup rewrite | Low | Non-goal |
Open Questions¶
All previously open product questions are resolved (final):
| # | Question | Decision | Key Decision |
|---|---|---|---|
| 1 | Publish to public npm? | Monorepo-local only for now. Do not publish @workspace-lite/shared or @workspace-lite/cli to the public registry before CLI GA. Revisit public npm after CLI GA (may require publishing shared as a dependency). | K16 |
| 2 | Interactive batch confirm detail? | Summary only: for each gated op show index, action, risk class. Do not dump full params (PII risk: recipients, message bodies, file content). | K20 |
| 3 | --raw in GA? | Keep behind WSLITE_ALLOW_RAW=1. Useful for debugging uncatalogued proxy actions. Not shown in default CLI help; document in operator/debug docs only. Always still runs applyConfirmPolicy. | K21 |
(Commander, config file, dual-bin, risk SSOT, vertical migration, and other choices remain locked in Key Decisions above.)
References¶
- Repo baseline:
master@60c58a7 README.md,AGENTS.mdconfig/service-registry.jsonshared/src/proxy-client.ts,schemas.ts,response.ts,tool-helpers.tspackages/*/src/tools/*,packages/*/apps-script/Policy.gs,*Service.gsbatch/runBatchscripts/validate-architecture.mjs,registry-helpers.mjstests/tool-registration.test.ts; root"test": "tsx --test tests/*.test.ts"(to be widened)docs/architecture/security.md,docs/operations/mutation-safety.md,docs/api/error-codes.mdskills/google-workspace/SKILL.md
PR Plan¶
Each service migration PR is a vertical slice: catalog module + MCP flip + delete old tools + validator/mappings for that service + parity tests. Independently reviewable and revertible.
PR 1 — Extract risk module + update architecture validator paths¶
- Title:
refactor(shared): extract token risk resolution to catalog/risk.ts - Files:
shared/src/catalog/risk.ts,shared/src/proxy-client.ts,tests/risk-resolution.test.ts,scripts/validate-architecture.mjs(update tokens that currently requirefunction dynamicActionTokenClassinsideproxy-client.tsto acceptrisk.tsor re-export shims) - Dependencies: none
- Description: Behavior-preserving move of
ACTION_TOKEN_CLASSES, dynamic gmail/calendar rules, batch admin promotion.TokenClasswithoutdraft. ExportisConfirmClass,staticRiskClass,resolveRiskClass,batchRiskClass.
PR 2 — Catalog types, naming, parseCliArgs, confirm-policy (no service port)¶
- Title:
feat(shared): catalog types, CLI parse/coercion, batch-aware confirm policy - Files:
shared/src/catalog/types.ts(incl.actions?,effectiveActions)naming.ts,parse-cli-args.ts,confirm-policy.ts,index.tsshared/package.jsonexports./catalogand./catalog/*tsconfig.typecheck.json— required path entries:@workspace-lite/shared/catalog→shared/src/catalog/index.ts@workspace-lite/shared/catalog/*→shared/src/catalog/*- keep existing
@workspace-lite/sharedand@workspace-lite/shared/*(less specific)
- unit tests for parse + confirm (including batch per-op inject and admin batch)
- Dependencies: PR 1
- Description: Land SSOT helpers. No MCP behavior change yet. Document that
ToolSpechas no hand-authoredriskClass. PR must not merge unlessnpm run typecheckresolves a smoke import of@workspace-lite/shared/catalogand a nested@workspace-lite/shared/catalog/...module (can be a tiny test file or temporary re-export).
PR 3 — Extend registerTool (resolveAction + format args)¶
- Title:
refactor(shared): registerTool resolveAction and format(result, args) - Files:
shared/src/tool-helpers.ts,shared/src/catalog/register-from-catalog.ts, tests for multi-action with mock client - Dependencies: PR 2
- Description: Complete registration algorithm. Existing
registerToolcallers remain valid (formatarity extended; old single-arg formatters still work if implemented to ignore args).
PR 4 — Tasks vertical slice (first SSOT service)¶
- Title:
feat(tasks): port tools to catalog and register from catalog - Files:
shared/src/catalog/services/tasks.ts,packages/tasks/src/index.ts, deletepackages/tasks/src/tools/**,scripts/registry-helpers.mjshybrid mapping for tasks,tests/catalog-parity.test.ts/ tool-registration updates,packages/tasks/src/proxy.tsthin re-export or delete - Dependencies: PR 3
- Description: Full port+flip+delete for 13 tasks tools in one PR. No dual registration left for tasks.
PR 5 — CLI package MVP (Commander, tasks surface)¶
- Title:
feat(cli): add wslite CLI over catalog (tasks-ready) - Files:
packages/cli/**, rootpackage.json(testglobtests/**/*.test.ts,build:packagesincludes cli, lint paths),tests/cli/*.test.ts,tests/invoke-parity.test.ts(tasks fixtures),packages/cli/README.md - Dependencies: PR 4 (tasks catalog exists)
- Description: Bin
wslite+workspace-lite. Global flags, doctor (presence-only), tools list,run,call(strict rules), sugaredwslite tasks …, confirm/--yes, exit matrix,--jsongoldens. Acceptance: tasks parity + CLI contracts — not full 218.
PR 6 — Forms vertical slice¶
- Title:
feat(forms): catalog port + MCP flip - Files: forms catalog + package tools delete + validators + tests
- Dependencies: PR 4 (pattern); PR 5 optional (CLI auto-picks new tools)
- Description: 16 tools; mixed registerTool/server.tool today.
PR 7 — Calendar vertical slice¶
- Title:
feat(calendar): catalog port + MCP flip - Files: calendar catalog, package, validators, tests (include validateDateOrder)
- Dependencies: PR 4 pattern
- Description: 22 tools; dynamic sendUpdates risk covered by risk tests.
PR 8 — Slides vertical slice¶
- Title:
feat(slides): catalog port + MCP flip - Files: slides catalog + package + tests
- Dependencies: PR 4 pattern
- Description: 25 tools.
PR 9 — Docs vertical slice¶
- Title:
feat(docs): catalog port + MCP flip - Files: docs catalog + package + tests
- Dependencies: PR 4 pattern
- Description: 26 tools.
PR 10 — Sheets vertical slice¶
- Title:
feat(sheets): catalog port + MCP flip - Files: sheets catalog + package + tests
- Dependencies: PR 4 pattern
- Description: 33 tools; many write/format tools; batch params-json-only.
PR 11 — Gmail vertical slice¶
- Title:
feat(gmail): catalog port + MCP flip - Files: gmail catalog + package + tests (filters/vacation dynamic confirm)
- Dependencies: PR 4 pattern
- Description: 39 tools alone (do not combine with drive). Custom list/read formatters ported to
formatMcp(result, args).
PR 12 — Drive vertical slice (final service)¶
- Title:
feat(drive): catalog port + MCP flip - Files: drive catalog + package + tests (
drive_list_folderswithactions: ['folderList','folderListRoot']+resolveAction, export formatters) - Dependencies: PR 4 pattern; preferably after PR 11
- Description: 44 tools; last scrape-path removals for drive. Catalog length should reach 218. CI asserts multi-action tools declare
actionsand that catalog mappings include both folder list actions (replacingregistry-helpers.mjsspecial-case).
PR 13 — Remove hybrid registry scrape; full invoke-parity matrix¶
- Title:
refactor(scripts): toolActionMappings from catalog only - Files:
scripts/registry-helpers.mjs,validate-architecture.mjs,tests/invoke-parity.test.tsexpanded sample matrix across services - Dependencies: PR 12 (all services flipped)
- Description: Delete regex scrape of
packages/*/src/toolsand thedrive_list_foldershard-coded dual-action branch. Build mappings viaeffectiveActions(tool)from catalog (one row per action). Assert catalog length === sum(toolCount).
PR 14 — Docs, security honesty, CLI guide, error codes, AGENTS.md¶
- Title:
docs: CLI guide, token class defaults, error codes, AGENTS honesty - Files:
README.md,docs/getting-started/cli.md,docs/architecture/overview.md,docs/architecture/security.md,docs/operations/security.md,docs/api/error-codes.md(addCONFIRMATION_REQUIRED,ACTION_NOT_ALLOWED,BATCH_ACTION_NOT_ALLOWED),AGENTS.md(state full defaultDEFAULT_AUTH_TOKEN_CLASSES),skills/google-workspace/SKILL.md+rules.md,CHANGELOG.md - Dependencies: PR 5 minimum for CLI docs; PR 12 for “218 tools” claims; required before CLI GA announcement
- Description: No behavior change. Security doc fix is blocking for GA, not optional.
PR 15 (optional) — Generate skill tool-catalog from catalog¶
- Title:
chore(skills): generate tool-catalog.md from shared catalog - Dependencies: PR 13
- Description: CI checks generated file.
PR 16 (optional) — proxy diagnostics + doctor class-token detail¶
- Title:
feat(shared,cli): optional token selection diagnostics for verbose/doctor - Dependencies: PR 5
- Description:
onTokenSelectedhook; verbose may log env names only.
PR 17 (optional) — installer verify uses wslite doctor¶
- Title:
feat(installer): optional wslite doctor in verify flow - Dependencies: PR 5, PR 14
- Description: Does not rewrite clasp.
Appendix A — Current registration pattern examples¶
Catalog-friendly (registerTool):
// packages/calendar/src/tools/calendar-write.ts
registerTool(server, client, {
name: 'calendar_create_event',
description: 'Create a new calendar event...',
schema: calendarCreateEventSchema,
action: 'createEvent',
summary: 'Event created.',
validate: validateDateOrder,
})
Custom formatter (server.tool):
// packages/drive/src/tools/drive-read.ts
server.tool('drive_get_file', '...', fileGetSchema, async (args) => {
const result = await callProxy('fileGet', args)
return formatResponse(result, { summary: '...', hint: '...' })
})
Both become ToolSpec entries; custom path sets formatter.formatMcp.
Appendix B — Env var checklist (unchanged)¶
GOOGLE_WORKSPACE_<SERVICE>_PROXY_URL
GOOGLE_WORKSPACE_<SERVICE>_PROXY_TOKEN
GOOGLE_WORKSPACE_<SERVICE>_PROXY_{READ,WRITE,SEND,SHARE,DESTRUCTIVE,ADMIN}_TOKEN
Appendix C — Batch confirm example¶
// ops.json BEFORE --yes
{
"operations": [
{ "action": "fileGet", "params": { "fileId": "abc" } },
{ "action": "fileTrash", "params": { "fileId": "abc" } }
]
}
// AFTER applyConfirmPolicy with --yes (fileTrash is destructive)
{
"operations": [
{ "action": "fileGet", "params": { "fileId": "abc" } },
{ "action": "fileTrash", "params": { "fileId": "abc", "confirm": true } }
]
}
Outer { "confirm": true, "operations": [...] } alone is insufficient for Policy.gs.
Appendix D — Suggested demo after PR 5¶
wslite doctor
wslite tools --service tasks
wslite tasks list-tasklists --json
wslite call tasks tasklistsList --params-json '{}'
End of design document (rev 4 — Ready for implementation / Approved).