The single Dispatch hook assumed every command is one MCP call, so the Shortcut projection could only ever describe a command, never run it. Split dispatch into Invoke (assembled toolArgs) and Orchestrate (multi-step), keep RunE as the escape hatch, and reject specs that declare anything other than exactly one at construction time. Ctx gives both hooks the same typed flag accessors so orchestration no longer reaches for framework-specific plumbing. Catalog output stays byte-identical.
18 KiB
RFC: Converging the DWS command frameworks onto one typed base
Status: draft, seeking review
Scope: internal/cmdcore, internal/helpers (LeafSpec), internal/shortcut
Related PR: #830 (landed the shared base; supersedes #827 and #829)
This document is self-contained: a reviewer without repository access should be able to judge the design from it.
1. Context
dws (DingTalk Workspace CLI) is a Go/Cobra CLI whose commands are thin wrappers
that dispatch a JSON-RPC tools/call to DingTalk MCP servers. It targets both
humans and AI agents, so every command also publishes an Agent-visible "Runtime
Schema" (parameters, constraints, safety/confirmation semantics).
1.1 Three command-definition systems (measured)
| System | Count | Shape | Notes |
|---|---|---|---|
Handwritten cobra.Command |
1195 | dws <product> <cmd> |
96% of commands; internal/helpers/*.go, 7.3万 lines |
| Shortcut | 376 | dws <product> +<cmd> |
Declarative + multi-step orchestration |
| LeafSpec | 27 | dws <product> <cmd> |
Declarative, single-step; only devapp |
Duplication measured inside the handwritten commands: mustGetFlag ×500,
callMCPTool* ×469, flagOrFallback ×306, required validation ×262,
AttachRuntimeSchema/AnnotateRuntimeConstraints ×165 each.
A static AST census classified the 1195 handwritten commands by how they could migrate:
| Class | Count | Criterion | Disposition |
|---|---|---|---|
| GROUP | 594 | no MCP call, no loop/IO (pure parent) | not migrated |
| A pure CRUD | 141 | exactly 1 MCP call, no loop/IO/confirm | 1:1 migratable |
| B confirm/fallback | 92 | 1 MCP call + confirm/dry-run/alias fallback | migratable |
| C multi-step / special IO | 368 | 0 or ≥2 MCP calls, loops, upload/poll | needs multi-step support |
So 233/601 (38.8%) of commands with a body are migratable today; the 368 C-class ones require the base to model multi-step orchestration.
1.2 What already landed (PR #830)
LeafSpecgained a declarative contract:at_least_one/exactly_one/mutually_exclusiveconstraints,bool+string-sliceflag kinds, andRisk-driven write confirmation honoring--dry-run/--yes(including root-injected globals, via a 3-level flag lookup).internal/cmdcore— a dispatch-agnostic shared base extracted verbatim fromleaf.go: flag registration, theexplicit → alias → env → defaultvalue chain, required validation, constraint declaration checks + runtime enforcement, Risk confirmation,toolArgsassembly, Runtime Schema projection, help rendering.cmdcore.CommandSpec+cmdcore.NewCommand— one typed definition and one orchestration path; dispatch became a spec field.helpers.FromLeafSpec;NewLeafCommandis now a one-line mapping. All LeafSpec commands (incl. all 27 devapp leaves) flow through the unified spec.shortcut.FromShortcut— a typed seam projecting a Shortcut's shared base into aCommandSpec, deliberately not wired into the livemount().
1.3 Verification discipline (already enforced, non-negotiable)
check-generated-drift: regenerate the Agent metadata + command catalog and byte-compare against the committed artifacts. For a refactor, a byte-identical catalog (840 tools, identical hashes) is the equivalence proof for the build-time projection — identity, flags, help, annotations.- Runtime semantics are NOT covered by drift. Required validation, args assembly, confirmation and dispatch order are evidenced only by unit tests. (An earlier version of this work overstated drift as proof of runtime behavior; that claim was corrected.)
- 100% changed-code coverage gate. Important subtlety: CI runs
go test -coverprofilewithout-coverpkg, so each package is measured only by its own tests. A new package whose logic is exercised only from a consumer package will fail the gate (this actually happened:cmdcoreself-coverage was 31.5%, CI changed-code coverage 40.5%, plus an overall regression — fixed by givingcmdcoreits own exhaustive tests). - Interface compatibility: the public Cobra command surface is diffed against both the PR merge-base and the latest stable GA tag.
2. Design review findings
An adversarial review found no BLOCKERs: the Phase-1 extraction was
mechanically verified as a verbatim move, and the unified BoolFlag was proven
equivalent to the two readers it replaced. The following are the structural
issues that motivate this plan. (Items marked ✅ were already fixed in #830.)
Structural
F1. Dispatch is single-step biased; the type permits an unrunnable value.
Dispatch func(cmd *cobra.Command, args []string, toolArgs map[string]any) error
This presumes "assemble toolArgs → make one call". A Shortcut's
Execute(rt *RuntimeContext) is arbitrary multi-step orchestration and cannot
be expressed. Consequently FromShortcut returned a spec that could not run.
✅ Partially mitigated in #830 by making NewCommand reject such a spec — but
that is a patch, not a model fix. A type that can be constructed into a
permanently unusable state means the model is missing a dimension.
F2. The base leaks the MCP payload shape.
BuildArgs → map[string]any is the MCP tools/call argument shape, yet it lives
in a package documented as dispatch-agnostic. devapp dispatches through
executor.Runner and is still handed a toolArgs map.
F3. The same field name carries two semantics.
Required: cmdcore accepts any effective value (including registration default and env fallback); Shortcut requires the flag to beChanged.- Decline behavior: LeafSpec/cmdcore return a typed validation error (exit ≠
0); Shortcut's
mount()returnsnil(exit 0).
These are semantic splits inside a "shared" base — documentation cannot fix them.
F4. The constraint model is incomplete and asymmetric with the Schema.
| Capability | Runtime Schema | cmdcore.Constraint |
|---|---|---|
mutually_exclusive |
✅ | ✅ |
require_one_of |
✅ | ✅ (at_least_one / exactly_one) |
require_together |
✅ | ❌ |
enum |
✅ (AnnotateRuntimeFlagEnum) |
❌ |
Anything unrepresentable falls back to the imperative Validate hook, which
loses the Schema projection and help rendering — destroying the main value of
the design ("declare once, take effect in three places").
F5. Too many hooks; PostMount is a back door.
Extension points: Validate + PostMount + RunE + dispatch. PostMount(cmd)
can rewrite anything on the built Cobra command (args, annotations, flags), so
any declarative guarantee can be bypassed — which is precisely why drift became
the only safety net. Measured real usage is narrow: devAppLeafMeta ×8 (attach
schema identity) and registerDevAppCursorFlags ×4 (pagination flags).
Secondary
F6. LeafSpec is now vestigial — 179 lines that are mostly comments and type
aliases; the only substantive own fields are the three dispatch fields
(Server/Tool/Call). It is a pure forwarding layer kept for 27 call sites.
F7. FlagSpec has 14 fields with three "two ways to do one thing" pairs:
Default vs ArgDefault, OmitEmpty vs Transform-returning-nil, Required
vs MarkRequired. This accreted from per-command special cases rather than a
clean model.
F8. "Single registry" currently covers ~2% of commands. The 368 C-class
handwritten commands are exactly the ones the current CommandSpec cannot
express, so F1 is a prerequisite for coverage, not an optional polish.
F9. The Schema still has two sources. AnnotateConstraints projects only two
constraint kinds; required / enum / require_together still come from the
separately reviewed registry + hint files. The declarative spec is not yet the
single source of parameter truth.
What is worth preserving
- Declare once → runtime validation + Schema projection +
--help(the core value). - Drift-as-equivalence-proof — makes a 400+ command migration verifiable.
- Type aliases for zero-churn migration (
type LeafFlag = cmdcore.FlagSpec). - Dispatch as a spec property rather than a framework identity.
3. Plan
Dependency graph
S1 dispatch layering + Ctx ──┬─→ S3 constraints/enum ──┐
│ ├─→ S8 Shortcut live-wire → S9 Schema single source
S2 semantics unification ────┘ │
(needs a product decision) │
S4 MCP shape push-down → S5 FlagSpec orthogonalization → S6 narrow PostMount → S7 delete LeafSpec
S1/S2/S3 are hard prerequisites for S8. S4→S7 is an independent cleanup chain that can proceed in parallel.
S1 — Dispatch layering + unified Ctx (biggest lever)
Fixes F1; unblocks the 368 C-class commands and S8.
// cmdcore: framework-neutral execution context (knows nothing about MCP)
type Ctx struct { /* cmd, args, declared flags */ }
func (c *Ctx) Command() *cobra.Command
func (c *Ctx) Args() []string
func (c *Ctx) Str(name string) string // reuses alias → env → default chain
func (c *Ctx) Int(name string) int
func (c *Ctx) Bool(name string) bool
func (c *Ctx) StrSlice(name string) []string
func (c *Ctx) Changed(name string) bool
func (c *Ctx) DryRun() bool
func (c *Ctx) Yes() bool
// CommandSpec: exactly one of the three, validated at CONSTRUCTION time
RunE func(cmd *cobra.Command, args []string) error // escape hatch
Invoke func(c *Ctx, toolArgs map[string]any) error // single-step
Orchestrate func(c *Ctx) error // multi-step
Notes:
Ctxaccessors honor the declared fallback chain, so a body reads exactly the value the required/constraint checks saw. This is stronger than Shortcut'sRuntimeContext, which reads flags bare.- Layering rule: MCP capability stays out of
cmdcore.shortcut.RuntimeContextkeepsCallMCPData/CallMCPWriteData/Output; theFromShortcutadapter builds it fromCtx.Command()insideOrchestrate. (Embedding*cmdcore.CtxintoRuntimeContextis deferred to S8 to avoid changing 376 shortcuts' flag-reading behavior in this step.) - Construction-time
panicreplaces the run-time error added in #830, consistent with the existingValidateConstraintDeclsphilosophy: a spec with no runnable body (or two competing ones) is a programming error that every test and startup path trips immediately.
Gates: drift zero; leaf suites green; three construction-panic cases;
Orchestrate confirmation/decline cases; Ctx accessor cases.
Risk: low (existing dispatch shifts to Invoke).
Effort: 1 PR (medium). Status: in progress on the branch.
S2 — Semantics unification (contains a decision that needs sign-off)
Fixes F3.
S2a. Required — make the divergence explicit instead of implicit:
type RequiredMode int
const (
RequiredAnyEffectiveValue RequiredMode = iota // = today's LeafSpec behavior (default)
RequiredMustBePassed // = today's Shortcut behavior (Changed)
)
During migration both modes coexist; at S8 each shortcut flag maps to
MustBePassed, so behavior is unchanged.
S2b. Decline behavior — ⚠️ user-visible contract change.
LeafSpec returns an error (exit ≠ 0); Shortcut returns nil (exit 0). Options:
- Converge on error (declining a destructive operation should exit non-zero). Changes the exit code of 376 shortcuts → needs product sign-off + CHANGELOG.
- Carry a
DeclineBehaviorfield through migration and converge later. - Converge on
nil(weakens the atomic-command contract; not recommended).
S8 must not start before this decision is made.
Gates: cases for both modes; Shortcut's existing suites unchanged. Effort: 1 PR (small) + 1 decision.
S3 — Complete the constraint model and add enum
Fixes F4; restores "declare once → three effects".
| Addition | Runtime validation | Schema projection | --help |
|---|---|---|---|
RequireTogether constraint |
✅ | RuntimeSchemaConstraints.RequireTogether (exists) |
✅ |
FlagSpec.Enum |
✅ | cli.AnnotateRuntimeFlagEnum (exists) |
(可选值: a, b) |
Required projection |
— | cli.AnnotateRuntimeRequiredFlags (exists) |
(必填) |
All three annotation APIs already exist in internal/cli, so this is wiring, not
new Schema surface.
By-product: the base can derive the 必填 / 可选值 help decoration itself, so
FromShortcut no longer needs to borrow Shortcut's flagHelp (removing a
coupling that caused a real bug — see §4).
Gates: drift zero (new annotations must appear only on commands that declare them; all existing commands unchanged) + runtime/help/schema cases for each addition. Risk: medium (touches Schema annotations; drift is the guard). Effort: 1 PR (medium).
S4 — Push the MCP shape out of the base
Fixes F2. Do it while only 27 call sites exist.
// base: CLI-facing contract
FlagSpec{ Name, Usage, Kind, Default, Aliases, EnvVar, Required, RequiredMode, Enum, Trim }
// MCP binding layer: payload shaping
ArgBinding{ Flag, Bind string; Transform func(string)(any,error); OmitEmpty bool; ArgDefault string }
BuildArgs moves to the MCP binding layer (helpers or cmdcore/mcpbind).
Gates: drift zero + per-key comparison of devapp's 27 commands' toolArgs.
Effort: 1 PR (medium-large; rewrites 27 call sites).
S5 — Orthogonalize FlagSpec
Fixes F7.
| Redundancy | Resolution |
|---|---|
Default vs ArgDefault |
S4 moves ArgDefault into ArgBinding — resolved naturally |
OmitEmpty vs Transform-nil |
keep OmitEmpty (declarative wins); document Transform-nil as the general escape |
Required vs MarkRequired |
merge into Required + EnforceBy{Framework,Cobra}; Cobra preserves the catalog required hard floor |
Gates: drift zero (the MarkRequired → catalog required projection must be
byte-identical).
Effort: 1 PR (small-medium).
S6 — Narrow PostMount
Fixes F5. Measured usage is only two kinds:
devAppLeafMeta(cmd, tool)×8 → named fieldSchemaIdentity{Product, Tool, Source}registerDevAppCursorFlags(cmd)×4 → namedExtraFlags []FlagSpec/Pagination
PostMount survives as an explicitly labelled escape hatch with a call-site
inventory (migration-era asset).
Gates: drift zero. Effort: 1 PR (small).
S7 — Delete LeafSpec
Fixes F6. Convert devapp's 27 sites to CommandSpec + an MCP dispatch
helper; delete LeafSpec, the type aliases, FromLeafSpec, NewLeafCommand.
Gates: drift zero + interface-compatibility dual baseline (the command surface must not change). Effort: 1 PR (medium).
S8 — Live-wire Shortcut (prerequisites: S1 + S2 + S3)
Migrate per service package (20 packages, 1 PR each), ordered
usage (smallest) → todo/contact → … → chat/aitable (largest).
Per-PR gates
- Generalize the existing mount-equivalence test into a table over
allShortcuts: for every registered shortcut, assertcmdcore.NewCommand(FromShortcut(s))andmount(s)agree byte-for-byte on flag set / types / usage /Long/ annotations. ← the most important guard. shortcut listoutput byte-identical (265 entries).dws schema --allbyte-identical.- drift zero + 100% changed-code coverage.
Still unmodeled before S8 (must be represented or explicitly rejected):
Flag.Hidden (no CommandSpec field), Shortcut.Tips → Example,
ConstraintCustom.
Effort: ~20 PRs.
S9 — Schema single source (endgame)
Fixes F9. Make the declarative spec the only source of parameter facts
(required / enum / require_together / constraints); schema_hints retains
only Agent selection prose (agent_summary / use_when / avoid_when).
Risk: high (touches the Schema generation pipeline's division of responsibility with the reviewed registry). Effort: separate project.
4. Bugs this design review already caught (evidence the gates matter)
Fixed in #830 after review:
- A spec with neither
RunEnorDispatchran the whole pipeline — including the write-confirmation prompt — then silently exited 0 having done nothing. FromShortcutdouble-rendered the参数约束help section (shortcutLongHelpalready appends it;NewCommandappendedConstraintHelpagain) and dropped the必填 / 可选值usage decoration. Both would have silently changed help and catalog output at S8.- A new mount-equivalence test (projected flag set/types/usage + rendered
Longvs livemount(s)) now catches this whole class; it is the seed of the S8 gate.
5. Handwritten commands (1195)
- After S1, the 368 C-class commands become expressible for the first time
(via
Orchestrate). - The 233 A/B-class commands are migratable today, but should be scheduled
after S4/S5, otherwise they get migrated onto the old
FlagSpecshape and then rewritten again. - The 594 GROUP nodes need no migration.
6. Global discipline (per PR, non-negotiable)
check-generated-driftzero drift = build-time projection equivalence proof.- Runtime semantics can only be proven by unit tests — drift cannot see them.
- 100% changed-code coverage, and a new package must have its own direct
tests (never rely on cross-package
coverpkg; this was a real CI failure). - Anything touching a user-visible contract (exit codes, flag names, help text) requires a CHANGELOG entry and an explicit human decision.
7. Questions for the reviewer
- S2b: should declining a risky operation exit non-zero (unify on error), or should Shortcut's exit-0 behavior be preserved? This is the only user-visible contract change in the plan.
- S1: is
Invoke/Orchestrate/RunEwith a construction-time "exactly one" rule the right factoring, or should dispatch be an interface (e.g.Dispatcherwith two implementations) instead of three nullable function fields? - S4: is splitting
FlagSpec(CLI-facing) fromArgBinding(payload-facing) worth the churn on 27 call sites now, versus keeping one struct and accepting the leak? - S8 ordering: is per-service migration with a byte-identity gate the right risk posture for 376 shipped shortcuts, or is a parallel "shadow mode" (build both, compare at run time) warranted?
- Is S9 worth attempting at all, given the reviewed-registry design intentionally separates identity/safety review from code?