Compare commits
168
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5c8982c00 | ||
|
|
402429ac2a | ||
|
|
54358e1195 | ||
|
|
4c5f8849d0 | ||
|
|
25a849d679 | ||
|
|
ae77915507 | ||
|
|
c494026305 | ||
|
|
6376f294da | ||
|
|
f48a707e04 | ||
|
|
7cb0de1f29 | ||
|
|
010d100e66 | ||
|
|
32598fb38d | ||
|
|
f8d1fb84c0 | ||
|
|
22a20355e7 | ||
|
|
ee286abb05 | ||
|
|
d3692e7b6e | ||
|
|
e2e855d12a | ||
|
|
bcb3b99faf | ||
|
|
9278a467b8 | ||
|
|
65ad8e0562 | ||
|
|
1e14ed4a87 | ||
|
|
082a9bb93a | ||
|
|
772bf462ee | ||
|
|
e40f5bc537 | ||
|
|
469d509cfb | ||
|
|
3210232876 | ||
|
|
ad5909b8a6 | ||
|
|
162a2eb0a7 | ||
|
|
3cce23e07d | ||
|
|
d3f62193e7 | ||
|
|
1a11c687ed | ||
|
|
dfed4ba37d | ||
|
|
f26df04679 | ||
|
|
fd6d3624c3 | ||
|
|
d02b03436d | ||
|
|
b877172d7d | ||
|
|
bc7b96ba5f | ||
|
|
28df903801 | ||
|
|
9539c887f6 | ||
|
|
b4b4a31979 | ||
|
|
6607f44724 | ||
|
|
837a96fe3d | ||
|
|
276837caae | ||
|
|
ec7f4deaf4 | ||
|
|
e135440b98 | ||
|
|
93d7e5a4c6 | ||
|
|
fe969dad51 | ||
|
|
fdcd44f9e3 | ||
|
|
34c0c86a59 | ||
|
|
affc8715be | ||
|
|
5c7407532a | ||
|
|
b1f4a5d62a | ||
|
|
bf33ab622f | ||
|
|
843ba7d81b | ||
|
|
7afd4139fc | ||
|
|
5b7bea8b11 | ||
|
|
0fbdfe0130 | ||
|
|
e5a47a2d18 | ||
|
|
2e51dcf35d | ||
|
|
e54927107f | ||
|
|
a5fd64a908 | ||
|
|
4262a50c16 | ||
|
|
9bc6a15232 | ||
|
|
9d1c3c5c95 | ||
|
|
ba72358f78 | ||
|
|
07fea09561 | ||
|
|
057a860dcc | ||
|
|
aeec39115f | ||
|
|
562c29905f | ||
|
|
7a945318e0 | ||
|
|
b1cefba808 | ||
|
|
124ddd85f2 | ||
|
|
99ca88597e | ||
|
|
22d3dd1096 | ||
|
|
4ad8cce5f3 | ||
|
|
c53ed8d383 | ||
|
|
d10738d0db | ||
|
|
f26968264d | ||
|
|
517eedb412 | ||
|
|
6210840b54 | ||
|
|
2d76433be0 | ||
|
|
b3adfa8d26 | ||
|
|
633862d07a | ||
|
|
2f31f48da0 | ||
|
|
3fb8631e5f | ||
|
|
a57e6bbfeb | ||
|
|
20a4eb77a4 | ||
|
|
e0bd2a88c0 | ||
|
|
059bdfd03e | ||
|
|
260d8ddddd | ||
|
|
c0808ab5e6 | ||
|
|
ea43db1d64 | ||
|
|
7257919a49 | ||
|
|
27f0fd4337 | ||
|
|
e4fde3917d | ||
|
|
812ec4f87e | ||
|
|
8d799979d4 | ||
|
|
2839d36631 | ||
|
|
f1eb1a44d1 | ||
|
|
6c15b591a3 | ||
|
|
d4f6aab04d | ||
|
|
9bf772ea06 | ||
|
|
a086be422a | ||
|
|
53816b3d33 | ||
|
|
0a2e49e7e4 | ||
|
|
ef39a1b8db | ||
|
|
9eccc0c9e0 | ||
|
|
dc5c9fe110 | ||
|
|
3d163242f5 | ||
|
|
2a1fe47c50 | ||
|
|
410fb05498 | ||
|
|
286a84edcd | ||
|
|
a26957c425 | ||
|
|
b8b2d9475e | ||
|
|
b7f04fd2a0 | ||
|
|
7fedbea806 | ||
|
|
6cdd781ae7 | ||
|
|
bb54761e50 | ||
|
|
3e83ac18d1 | ||
|
|
75bd1f1447 | ||
|
|
a26d219b27 | ||
|
|
46af37669c | ||
|
|
c2e4a85ba9 | ||
|
|
e29354ca70 | ||
|
|
0119f6e2a8 | ||
|
|
e98586ebb0 | ||
|
|
bd45de95ab | ||
|
|
066094a68c | ||
|
|
31cade34ff | ||
|
|
e705012011 | ||
|
|
8791088027 | ||
|
|
746b7e403c | ||
|
|
86e34b5489 | ||
|
|
b64438d01c | ||
|
|
4943c6ff49 | ||
|
|
f0fef85905 | ||
|
|
7773eb27f1 | ||
|
|
18e32ad09d | ||
|
|
a9857d94d7 | ||
|
|
461455b4fa | ||
|
|
f0d558a0d1 | ||
|
|
3cf690e779 | ||
|
|
340f01e95c | ||
|
|
621229fca2 | ||
|
|
acfbd35aa2 | ||
|
|
95a5fd069a | ||
|
|
d6292d92d3 | ||
|
|
afb9c27560 | ||
|
|
08c9c8a923 | ||
|
|
f805c966d6 | ||
|
|
eae7955448 | ||
|
|
ce0501b93e | ||
|
|
da93fbcc47 | ||
|
|
92f1daa95f | ||
|
|
9911d8f9c9 | ||
|
|
52324e9bc2 | ||
|
|
a80ac662f5 | ||
|
|
a5cefd67f3 | ||
|
|
8c9c22f4b0 | ||
|
|
37a05db6e9 | ||
|
|
41fdf79aea | ||
|
|
ceca98c573 | ||
|
|
c84a42b0d5 | ||
|
|
fb7696293d | ||
|
|
93d6e1a001 | ||
|
|
4d4817d74f | ||
|
|
110780bf74 | ||
|
|
26b100c6bb |
@@ -490,7 +490,8 @@ jobs:
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# cli/smoke shards need headroom beyond go test -timeout for setup + assembly.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -498,6 +499,8 @@ jobs:
|
||||
- app
|
||||
- generators
|
||||
- helpers
|
||||
- cli
|
||||
- smoke
|
||||
- remaining
|
||||
steps:
|
||||
- name: Check out repository
|
||||
@@ -523,7 +526,13 @@ jobs:
|
||||
test -n "$package_output"
|
||||
mapfile -t packages <<< "$package_output"
|
||||
test "${#packages[@]}" -gt 0
|
||||
go test -v -race -count=1 -timeout=12m "${packages[@]}"
|
||||
# cli/smoke own heavy NewRootCommand / Schema assembly under -race; give
|
||||
# them a dedicated budget so remaining is not SIGTERM'd by OOM/timeout.
|
||||
timeout_budget=12m
|
||||
if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then
|
||||
timeout_budget=15m
|
||||
fi
|
||||
go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"
|
||||
|
||||
test-release-scripts:
|
||||
name: Test (workflow and release contracts)
|
||||
@@ -693,8 +702,14 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Full ./internal/app (including schema --all assembly) already runs in the
|
||||
# race:app shard. Keep this job focused on native auth/keychain paths so
|
||||
# heavy Schema completeness tests cannot exhaust the 10m budget.
|
||||
- name: Test macOS auth and Keychain paths with Race Detection
|
||||
run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth ./internal/app
|
||||
run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth
|
||||
|
||||
- name: Test macOS auth migration and portable auth diagnostics
|
||||
run: go test -v -race -count=1 -timeout=5m ./internal/app -run '^Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch|ExportRejectsWindowsDPAPIBackend|ImportRejectsWindowsDPAPIBackend)'
|
||||
|
||||
test-windows:
|
||||
name: Test (Windows)
|
||||
|
||||
@@ -5,21 +5,75 @@ unrelated work, and use `gofmt` for every modified Go file.
|
||||
|
||||
## Build and test
|
||||
|
||||
- Build: `go build ./cmd`
|
||||
- Build: `make build` (wraps `scripts/dev/build.sh` → `go build -o dws ./cmd`; bare `go build ./cmd` fails because output name `cmd` collides with the directory)
|
||||
- Full test suite: `DWS_PACKAGE_VERSION=0.0.0-test go test ./...`
|
||||
- Generate Schema assets: `go generate ./internal/cli` (entry point: `internal/cli/gen.go`)
|
||||
- Refresh pinned MCP metadata: `make fetch-mcp-metadata` (requires `dws auth login`)
|
||||
- Check generated drift: `./scripts/policy/check-generated-drift.sh`
|
||||
- Param aliases generate: `go generate ./internal/cli` (entry point: `internal/cli/gen.go`; Catalog is not generated)
|
||||
- Optional diagnostic MCP dump (not a Schema pin): `make fetch-mcp-metadata` (requires `dws auth login`; writes under `artifacts/`)
|
||||
- Check generated drift + assembly determinism: `./scripts/policy/check-generated-drift.sh`
|
||||
- Check the Schema contract: `./scripts/policy/check-schema-catalog.sh`
|
||||
- Coverage-gate test naming: tests that carry coverage for the macOS platform
|
||||
gate must be named `TestCrossPlatformCoverage*` (or `TestAllShortcuts*`);
|
||||
`scripts/policy/run-platform-coverage-gate.sh` only selects those prefixes,
|
||||
so a covering test with any other name silently leaves its target uncovered.
|
||||
- Package-var injection seams (e.g. `pipelineBuildEffectiveRegistry`): swap
|
||||
them in tests only via `testseam.Swap(t, &seam, stub)` from
|
||||
`internal/testseam` — it restores the previous value through `t.Cleanup`
|
||||
structurally. Like the manual pattern it replaces, Swap mutates global state
|
||||
and is **not** safe for `t.Parallel` tests.
|
||||
- Cross-package test helpers (e.g. `StoreProductDeclRawForTest`) live in
|
||||
per-package `fortest.go` files, never scattered through production files;
|
||||
the `ForTest` suffix is the boundary and production code must not call them.
|
||||
|
||||
Generated Schema JSON is committed. Change its source inputs and generators,
|
||||
then regenerate; do not hand-edit generated Catalog or Agent metadata files.
|
||||
`internal/cli/schema_command_registry.json` is different: it is a reviewed
|
||||
`CommandRegistry` source, not a generated snapshot. It is the single reviewed
|
||||
source of stable canonical identity,
|
||||
primary paths, aliases, and navigation. Edit it only when reviewed exposure,
|
||||
identity, primary path, or aliases change; parameter, Skill, and metadata-only
|
||||
changes must not rewrite it mechanically.
|
||||
Schema Catalog delivery is **声明即 Catalog**: production assembles via
|
||||
`RegisterSchemaSourceRoot` → `ResolveSchemaBuild` (factory registered in
|
||||
`internal/app`). There is no
|
||||
`cmd_schema_catalog` `//go:generate` delivery step. `dws schema -f json` remains
|
||||
the wire projection. `cmd_schema_catalog` produces CI/local dumps only;
|
||||
`internal/cli/schema_catalog/`, `internal/cli/schema_meta_index.gob`, and
|
||||
`internal/cli/schema_meta_index.json` must not be committed. `schema_agent_metadata/` is retired: if that directory
|
||||
(or `schema_agent_metadata_audit.json`) is present, policy fails.
|
||||
Command identity is no longer a file input: it is collected from
|
||||
`ContractFinal.Identity` on the live Cobra leaves
|
||||
(`internal/cli/schema_identity_collect.go` → `BuildEffectiveCommandRegistry`).
|
||||
The reviewed `schema_command_registry/` was retired together with that
|
||||
switchover and must not reappear; identity changes happen by editing the leaf
|
||||
declaration. The remaining **reviewed inputs** under `internal/cli` (see Agent
|
||||
Schema contract) keep separate authorities — do not merge them with
|
||||
`param_concepts.json` or promote any of them into Catalog declaration.
|
||||
|
||||
## Command framework declaration
|
||||
|
||||
- Framework definition: `docs/rfc-command-framework-convergence.md` **§5.0**
|
||||
- Today: `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
|
||||
- **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` (`corecmd.ContractDecl`; nested fields are `contract.*`)
|
||||
- Naming: `ContractDecl` is the authoring leaf declaration. "Schema" means Catalog / `ToolSpec` delivery — do not reintroduce `SchemaDecl`.
|
||||
- `Safety` uses `contract.SafetySpec` (`internal/corecmd/contract` only — no `cli.*` type alias). Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Contract` is set, convert once via `contractfinal.RegisterRuntimeContractFinal` (all callers — `corecmd.New` registers internally); assembly **pass-throughs** Final.
|
||||
- Package seam:
|
||||
- types / ProductDecl → `corecmd/contract` (DTO only; **no** Cobra-keyed ContractFinal store)
|
||||
- AnnotateRuntime* writers → `internal/corecmd/runtimeannotate` (framework-owned)
|
||||
- ContractFinal cobra store + Register → `internal/corecmd/contractfinal` (framework-owned)
|
||||
- homology gates → `internal/cli/homology`
|
||||
- Catalog assembly / `ResolveMeta` (`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`); go:embed only for reviewed inputs → `internal/cli` root (package-local aliases for annotate/store APIs live in `runtime_schema_seam.go`; the former `cli/runtimeannotate` / `cli/contractfinal` shim packages are removed — import `corecmd/*` directly)
|
||||
- **Hard rule**: `internal/corecmd` (and its subpackages) must **not** import any `internal/cli` package
|
||||
- Authoring tiers (current, not aspirational):
|
||||
- **Tier1** — `corecmd.New` / `helpers.NewLeafCommand` (fully managed declare + execute)
|
||||
- **Tier2** — `DeclareLeafMetadata` (helpers migration; **Shortcut may also use this path — acceptable**)
|
||||
- **Tier3** — bare Cobra (should shrink over time; reviewed exclusions where needed)
|
||||
- Long-term outlook only: broader mcpbind / fewer hand-written `Execute` bodies. **Not** a current hard requirement to delete `Shortcut.Execute` or force mcpbind.
|
||||
- Description declare vs delivery: construction requires `ContractDecl.Description` (evidence). Catalog delivery prefers Cobra Long → provenance `cobra_help`; without Long, declared text → `contract_final`. Title: declared first, then Short, then MCP. Do **not** read this as "declare = wire final" or dual authority.
|
||||
- **Execute** = hooks (`Validate` / `Call` / `RunE` / `PostMount`) — not a second surface authority
|
||||
- Declaration path has **no reviewed parallel fields**; migration-only `runtime_gate` annotate until `Safety` is declared
|
||||
- **Do not add** new production `AnnotateRuntimeRisk` / `AnnotateRuntimeGate`
|
||||
(`runtime_gate`) call sites; migrate leaves to declared `Safety` /
|
||||
`ContractDecl` instead. Existing annotate sites may remain until migrated.
|
||||
|
||||
## flag / help / schema homology
|
||||
|
||||
- Decision (path A — Contract/LeafSpec is CLI-surface authority **and must embed into Schema**): `docs/flag-help-schema-homology.md`
|
||||
- Hard rule: every help/Schema fact is **declared** **or** **annotated**; never inference-only (§1.1–§1.3; framework §5.0).
|
||||
- Embed path: `corecmd.New` → `dws.schema.*` annotations → Schema catalog assembly
|
||||
- MCP metadata must not create CLI flags; optional 1:1 passthrough is a gated subset only.
|
||||
- Gate IDs: `HOM-P*`, `HOM-S*`, `HOM-I1`, `HOM-D1` (see that doc §3–§4). `HOM-P1`/`HOM-D1`/`HOM-S1`/`HOM-S2` are on the `check-schema-catalog.sh` policy whitelist; remaining IDs land incrementally.
|
||||
|
||||
## Agent Schema contract
|
||||
|
||||
@@ -28,90 +82,115 @@ The Schema data flow is one way:
|
||||
```text
|
||||
1. app.NewRootCommand()
|
||||
└─ builds the real Cobra command tree and flags
|
||||
└─ leaf Safety / Contract / contract.ParamDecl declare ContractFinal (declare-or-annotate)
|
||||
|
||||
2. schema_command_registry.json
|
||||
+ schema_hints/metadata/<product>.json tool parameters (+ cli_path)
|
||||
2. CollectIdentitySpecs (ContractFinal.Identity on live Cobra leaves)
|
||||
└─ forms EffectiveCommandRegistry
|
||||
└─ binds exactly to real Cobra leaves and aliases
|
||||
|
||||
3. Parameter resolution
|
||||
Cobra flags
|
||||
+ schema_parameter_bindings.json
|
||||
+ metadata tool parameters
|
||||
+ contract.ParamDecl.Property / native annotations (primary property authority)
|
||||
+ schema_parameter_mapping_ledger.go (mapping_exclusions / removals only;
|
||||
active bindings JSON retired after Track 1 Phase 2)
|
||||
└─ produces ParameterSpec and constraints
|
||||
|
||||
4. Agent and interface semantics
|
||||
schema_hints/selection/<product>.json (selection prose)
|
||||
+ schema_hints/metadata/<product>.json (safety/interface/runtime_gate)
|
||||
+ pinned MCP metadata
|
||||
ProductDecl + leaf ContractFinal Selection / Safety / Interface
|
||||
+ contract.ParamDecl (interface_type / property)
|
||||
└─ resolves Agent metadata by source precedence
|
||||
Markdown is evidence only; it is not concatenated into final prose
|
||||
└─ schema_hints/ and schema_mcp_metadata.json are fully retired
|
||||
|
||||
5. One typed hub
|
||||
5. One typed hub
|
||||
BoundCommandRegistry
|
||||
+ ParameterSpec
|
||||
+ Agent metadata
|
||||
+ Interface metadata
|
||||
└─ resolves every command exactly once into ToolSpec
|
||||
└─ aggregates SchemaRegistry + SchemaIndex
|
||||
└─ aggregates SchemaRegistry + SchemaIndex
|
||||
└─ ResolveSchemaBuild assembles at runtime; deliverySchemaCatalog wraps it (lazy, sync.Once)
|
||||
|
||||
6. One-way publication
|
||||
6. Runtime delivery (no generate-written Catalog authority)
|
||||
SchemaRegistry
|
||||
└─ internal/cli/schema_catalog/
|
||||
(catalog.json + tools/<product>.json; split per product so
|
||||
concurrent feature PRs only rewrite their own shard)
|
||||
└─ dws schema list/product/group/leaf/--all
|
||||
|
||||
7. Runtime consumption (unified API)
|
||||
ResolveMeta(cliPath) → CommandMeta{Identity, Safety, Selection}
|
||||
└─ internal/cli/command_meta.go
|
||||
└─ all consumers (help, schema, agent, skill-gen) call this one function
|
||||
└─ backed by embedded catalog (sync.Once lazy map, O(1) lookup)
|
||||
└─ dws schema list/product/group/leaf/--all (-f json wire)
|
||||
└─ ResolveMeta projects Identity/Safety/Selection from the same registry
|
||||
└─ CI may dump Catalog via cmd_schema_catalog for jq gates / determinism
|
||||
```
|
||||
|
||||
Parameter overlays from metadata are merged into `EffectiveCommandRegistry`
|
||||
*before* Cobra binding; after that point there is no second identity source and
|
||||
no identity precedence winner. The binder must reject a missing/non-runnable
|
||||
Cobra path, an alias collision, and any native identity annotation that
|
||||
disagrees with the effective registry. A missing native identity annotation is
|
||||
allowed because annotations are implementation-side assertions, not identity
|
||||
fallbacks.
|
||||
**Reviewed inputs / 评审输入** (organizational family under `internal/cli`;
|
||||
parallel peers, not one merged authority). These are assembly inputs only —
|
||||
never Catalog declaration authority, never leaf `Contract` / `ProductDecl`
|
||||
substitutes. Keep them side-by-side; do **not** fold one into another:
|
||||
|
||||
| Input | Path | Owns |
|
||||
|---|---|---|
|
||||
| Command identity | collected from `ContractFinal.Identity` on live Cobra leaves (`schema_identity_collect.go`; not a file input) | stable identity, primary CLI path, aliases, navigation |
|
||||
| Param concepts | `param_concepts.json` (+ `.schema.json`) | argv synonym / concept dictionary (reduced to `param_aliases_generated.go`) |
|
||||
| Exclusions | `schema_command_exclusions.go` | exact reviewed CLI paths excluded from Schema (non-empty reason) |
|
||||
| Mapping ledger | `schema_parameter_mapping_ledger.go` | `mapping_exclusions` / removals (CLI flags with no direct RPC property); active bindings JSON retired |
|
||||
|
||||
`schema_mcp_metadata.json` is retired and must not reappear. Interface facts
|
||||
(`interface_ref`, `interface_type`, …) declare on leaf `Contract` /
|
||||
`contract.ParamDecl`. Retiring the pin cleared MCP-sourced `interface_type`
|
||||
values from the wire; schema-compat deliberately accepts clearing (missing =
|
||||
unknown for consumers) while still rejecting any change to a different
|
||||
non-empty value. Re-populating a value requires an explicit `ParamDecl`
|
||||
declaration, not a new pin.
|
||||
|
||||
**Aliases are three distinct layers** (do not conflate):
|
||||
|
||||
| Layer | Owns |
|
||||
|---|---|
|
||||
| `FlagSpec.Aliases` / Cobra flag aliases | executable flag synonyms on a leaf |
|
||||
| `ContractFinal.Identity` `aliases` | reviewed CLI-path aliases for the same command identity |
|
||||
| `param_concepts.json` | argv synonym / concept dictionary (central preparse normalization) |
|
||||
|
||||
**Visibility vs exclusions:** collected identity `visibility` is dormant (all
|
||||
entries default `public`); “runnable but not Agent-visible” belongs in
|
||||
`schema_command_exclusions.go`, not new `visibility` values. Native identity
|
||||
annotations are consistency assertions only — they must agree with the
|
||||
collected identity and never materialize or override it.
|
||||
|
||||
Leaf declare (`Contract` / `ParamDecl` / `Safety` / `ProductDecl`) and the live
|
||||
Cobra tree remain separate from this table: declare owns semantics; Cobra owns
|
||||
executability and flags.
|
||||
|
||||
After binding there is no second identity source and no identity precedence
|
||||
winner. The binder must reject a missing/non-runnable Cobra path, an alias
|
||||
collision, and any native identity annotation that disagrees with the effective
|
||||
registry. A missing native identity annotation is allowed because annotations
|
||||
are implementation-side assertions, not identity fallbacks.
|
||||
|
||||
The assembler resolves every bound command exactly once into one `ToolSpec`.
|
||||
Build-time gates and the snapshot serializer consume that source-resolved typed
|
||||
registry/index. Runtime projections and delivery gates consume the typed
|
||||
registry/index returned by the production snapshot loader. Neither path may
|
||||
reopen annotations, merge source records, or use a previous Catalog or other
|
||||
generated JSON as a source. `schema_catalog/` (catalog.json + per-product
|
||||
tools/<product>.json shards) is output-only in the
|
||||
generation graph. The production loader decoding the embedded published
|
||||
snapshot is a delivery boundary, not source resolution; it must never create or
|
||||
repair a Cobra command, flag, registry entry, or later Catalog generation.
|
||||
CI determinism (`check-schema-assembly.sh`) and policy jq gates consume a
|
||||
fresh assembly dump; runtime consumes the same `ResolveSchemaBuild` path via
|
||||
`RegisterSchemaSourceRoot`. Neither path may reopen annotations, merge source
|
||||
records, or use a previous Catalog JSON as a source.
|
||||
|
||||
### Generation vs consumption separation
|
||||
### Assembly vs consumption
|
||||
|
||||
The Schema system has two physically separated processes:
|
||||
**Assembly** (declare → typed registry; CI + runtime):
|
||||
- Runtime entry: `RegisterSchemaSourceRoot` (`internal/app`) →
|
||||
`ResolveSchemaBuild` / `deliverySchemaCatalog` (lazy, sync.Once).
|
||||
- CI tool: `cmd_schema_catalog` dumps an assembled Catalog for jq/determinism;
|
||||
it is **not** a `//go:generate` or committed delivery step.
|
||||
- `gen.go` only generates `param_aliases_generated.go`.
|
||||
- Inputs: **reviewed inputs** (param_concepts / exclusions / mapping ledger —
|
||||
see table above) + ProductDecl/ContractFinal (identity is collected from
|
||||
`ContractFinal.Identity`) + live Cobra tree.
|
||||
`schema_hints/`, `schema_agent_metadata/`, `schema_command_registry/`, and
|
||||
`schema_mcp_metadata.json` must not reappear.
|
||||
- Gates: `make generate-schema` (param aliases + assembly determinism),
|
||||
`check-generated-drift.sh`, `check-schema-catalog.sh`.
|
||||
|
||||
**Generation** (build-time, slow, reviewed, one-way):
|
||||
- Entry point: `internal/cli/gen.go` (all `//go:generate` pragmas isolated here,
|
||||
not in business code).
|
||||
- Tools: `internal/generator/cmd_schema_agent_metadata` + `cmd_schema_catalog` +
|
||||
`cmd_param_aliases` (standalone Go mains).
|
||||
- Inputs: 7 authored source groups (registry + hints metadata + hints selection +
|
||||
MCP metadata + parameter bindings + reviewed parameter concepts + cobra tree).
|
||||
- Output: `schema_catalog/` (per-product shards) + `schema_agent_metadata/` +
|
||||
`param_aliases_generated.go`.
|
||||
- Refresh MCP metadata: `make fetch-mcp-metadata` (iterates 26 MCP server
|
||||
endpoints, merges with previous data for cross-server interface_ref).
|
||||
- Gates: `make generate-schema` (byte guards on inputs), `check-generated-drift.sh`,
|
||||
`check-command-surface.sh` (catalog structure).
|
||||
|
||||
**Consumption** (runtime, fast, read-only, unified API):
|
||||
**Consumption** (runtime, unified API):
|
||||
- Entry point: `ResolveMeta(cliPath) → CommandMeta{Identity, Safety, Selection}`
|
||||
in `internal/cli/command_meta.go`.
|
||||
- Backed by embedded catalog (`embeddedSchemaCatalog()`, sync.Once, O(1) map).
|
||||
in `internal/cli/command_meta.go` — projected from the assembled registry
|
||||
when the app factory is registered.
|
||||
- Consumers: `--help` (Safety annotation via `RenderSafetyAnnotation`),
|
||||
`dws schema`, agent selection, future skill generation.
|
||||
agent selection, future skill generation; `dws schema` uses the same
|
||||
assembled Catalog (`-f json` wire unchanged).
|
||||
- `SafetyForCLIPath` delegates to `ResolveMeta` (backward compatible).
|
||||
|
||||
This split is architecturally isomorphic to Lark's typed metadata registry,
|
||||
@@ -119,16 +198,17 @@ navigation catalog, and schema renderer. DWS intentionally preserves its
|
||||
existing flat JSON wire contract for compatibility; do not treat architectural
|
||||
alignment as permission to make an unversioned wire-format change.
|
||||
|
||||
The reviewed `CommandRegistry` is the sole source of stable command identity
|
||||
and navigation. The executable Cobra tree remains the source of truth for
|
||||
whether a CLI path exists, is runnable, and which flags it accepts. Schema
|
||||
coverage is bidirectional:
|
||||
The identity collected from `ContractFinal.Identity` (via
|
||||
`CollectIdentitySpecs`) is the sole source of stable command identity and
|
||||
navigation. The executable Cobra tree remains the source of truth for whether
|
||||
a CLI path exists, is runnable, and which flags it accepts. Schema coverage is
|
||||
bidirectional:
|
||||
|
||||
1. Every final `SchemaRegistry` tool, including its serialized Catalog
|
||||
projection, must resolve to an executable Cobra command.
|
||||
2. Every public runnable Cobra leaf must either resolve to Schema or appear as
|
||||
an exact, reviewed exclusion with a non-empty reason in
|
||||
`internal/cli/schema_command_exclusions.json`.
|
||||
`internal/cli/schema_command_exclusions.go` (central Go groups; not JSON).
|
||||
|
||||
Do not use prefix or wildcard exclusions: they can silently hide future
|
||||
commands. Remove an exclusion when its command enters Schema; stale, invalid,
|
||||
@@ -136,104 +216,105 @@ or duplicate exclusions must fail generation and CI.
|
||||
|
||||
When adding or changing an Agent-visible command, review all relevant inputs:
|
||||
|
||||
- `internal/cli/schema_command_registry.json` for the reviewed
|
||||
`CommandRegistry`: canonical identity, primary CLI path, aliases, and stable
|
||||
navigation. It is the identity source and is not a generated artifact.
|
||||
- `internal/cli/schema_command_registry.schema.json` is its closed,
|
||||
machine-readable editing contract. Preserve the local `$schema` reference;
|
||||
unknown fields, invalid visibility values, stale paths, and collisions fail
|
||||
Go validation and policy.
|
||||
- `internal/cli/schema_hints/metadata/<product>.json` for safety, interface,
|
||||
`runtime_gate`, and optional parameter overlays (`parameters` / `cli_path`).
|
||||
- `internal/cli/schema_hints/selection/<product>.json` for reviewed Agent
|
||||
selection prose (`agent_summary`, `use_when`, `avoid_when`, `examples`).
|
||||
- `internal/cli/schema_hints/index.json` only maps product IDs to those files.
|
||||
- Leaf `ContractFinal.Identity` for canonical identity, primary CLI path,
|
||||
aliases, and stable navigation. Identity is collected from the live Cobra
|
||||
leaves (`CollectIdentitySpecs`); there is no separate identity file. Invalid
|
||||
canonical paths, alias collisions, stale paths, and drift fail collection,
|
||||
binding, and policy.
|
||||
- Leaf `Safety` / `Contract` (`corecmd.ContractDecl`) / `contract.ParamDecl`
|
||||
(helpers `LeafSpec` or shortcut `Contract`) for parameter facts, interface
|
||||
disposition, safety, and Agent selection prose. Delivered provenance is
|
||||
`contract_final` from `corecmd.contract` (description may stamp `cobra_help`
|
||||
when Cobra Long wins). Product routing uses `ProductDecl`
|
||||
(`internal/corecmd/contract`; provenance label remains `cli.product_decl`).
|
||||
- `internal/cli/schema_hints/` is fully retired. Do not reintroduce HintFiles,
|
||||
audit JSON, or `imported/` baselines; declare on ProductDecl / the owning
|
||||
leaf instead.
|
||||
- Native Runtime Schema identity annotations, when present, as consistency
|
||||
assertions against `EffectiveCommandRegistry`. They must agree exactly and
|
||||
must never materialize, infer, or override registry identity.
|
||||
- Flag-to-interface property mappings and required/default semantics.
|
||||
- Generated files under `internal/cli/schema_agent_metadata/` and
|
||||
`internal/cli/schema_catalog/` (catalog.json + tools/<product>.json) after
|
||||
running generation.
|
||||
- Do not expect generate-written Catalog delivery. Run
|
||||
`make generate-schema` only to refresh param aliases and prove assembly
|
||||
determinism. Do not expect or commit `schema_agent_metadata/`.
|
||||
|
||||
Run the reverse-completeness tests whenever the Cobra tree changes. A command
|
||||
that works through `dws <path>` but cannot be found through the matching
|
||||
`dws schema` lookup is a contract failure unless it has a reviewed exact
|
||||
exclusion.
|
||||
|
||||
Metadata parameter overlays must reference an exact public runnable Cobra leaf
|
||||
and real flags. They may override Schema description, interface-property/type
|
||||
mapping, `required`, and `required_when`; they must not create commands or
|
||||
flags, define an interface, or advertise an unknown RPC. Every authored entry
|
||||
requires `reviewed: true` and a non-empty review reason.
|
||||
`RegisterSchemaHints` / `ToolSchemaHint` overlays are fully removed. Parameter
|
||||
and selection facts must be declared on the owning leaf (`contract.ParamDecl` /
|
||||
`Contract`) or via `ProductDecl`; do not reintroduce overlay registries.
|
||||
|
||||
For Agent-authored metadata or selection edits:
|
||||
For Agent-authored selection edits:
|
||||
|
||||
1. Confirm the exact command and flag names in the current Cobra tree.
|
||||
2. Edit only the owning block (`metadata/` or `selection/`); do not mix fields.
|
||||
3. Add the smallest possible entry; do not copy generated Catalog fields into
|
||||
the input.
|
||||
4. Describe user-visible semantics in `review_reason` and parameter
|
||||
descriptions.
|
||||
5. Run generation, drift, Schema policy, and the focused CLI tests before
|
||||
2. Declare selection prose on the owning leaf (`Contract.Selection` /
|
||||
`DeclareLeafMetadata`) and product routing via `ProductDecl`; declare
|
||||
safety / parameters / interface on the same leaf.
|
||||
3. Do not copy generated Catalog fields into source inputs.
|
||||
4. Run generation, drift, Schema policy, and the focused CLI tests before
|
||||
proposing the change.
|
||||
|
||||
## Agent curation workflow (Schema hints)
|
||||
## Agent curation workflow
|
||||
|
||||
Use this workflow when refreshing Agent selection prose and confirmation
|
||||
alignment. Prefer **agent-authored review** over bulk merge scripts that dump
|
||||
`selection-review.json` or Skill Markdown into Catalog fields.
|
||||
Skill Markdown into Catalog fields.
|
||||
|
||||
Human-authored inputs are split into two blocks:
|
||||
Human-authored inputs:
|
||||
|
||||
| Block | Path | Owns |
|
||||
|---|---|---|
|
||||
| **metadata** | `internal/cli/schema_hints/metadata/<product>.json` | `effect` / `risk` / `confirmation` / `idempotency` / `interface_*` / `runtime_gate` / optional `parameters` |
|
||||
| **selection** | `internal/cli/schema_hints/selection/<product>.json` | `agent_summary` / `use_when` / `avoid_when` / `examples` (+ product routing) |
|
||||
| **declaration** | helpers / shortcut `Safety` + `Contract` / `contract.ParamDecl` + `ProductDecl` | `effect` / `risk` / `confirmation` / `idempotency` / `interface_*` / parameter facts / selection prose (`contract_final`) |
|
||||
|
||||
`index.json` only maps product IDs to those files. Do not mix selection fields
|
||||
into metadata files or metadata fields into selection files.
|
||||
`schema_hints/` is fully retired. Do not reintroduce HintFiles or audit JSON.
|
||||
|
||||
### Goals
|
||||
|
||||
1. **Selection prose** is decision-oriented (Feishu/Lark style): trigger intent,
|
||||
sibling-command routing, and outcome shape — not a restatement of the
|
||||
summary. Delivered Catalog provenance is `reviewed_explicit` from
|
||||
`selection/`.
|
||||
2. **Safety** follows Runtime: `confirmation=user_required` iff the tool's
|
||||
metadata `runtime_gate != none` (for example `confirm_delete`, `typed_yes`,
|
||||
`confirm_dangerous`).
|
||||
3. **Parameter overrides** (former Manual `commands`) live on metadata tools as
|
||||
`parameters` (+ `cli_path`) and are applied into EffectiveCommandRegistry.
|
||||
summary. Delivered Catalog provenance is `contract_final` from leaf
|
||||
`Contract.Selection` / `ProductDecl`.
|
||||
2. **Safety** follows Runtime: `confirmation=user_required` when the leaf
|
||||
Contract/Safety (or remaining `runtime_gate` annotate) requires a user gate
|
||||
(for example `confirm_delete`, `typed_yes`, `confirm_dangerous`).
|
||||
3. **Parameter facts** are declared on the leaf (`contract.ParamDecl` /
|
||||
`Contract.Parameters` / FlagSpec). Do not reintroduce HintFile or
|
||||
`RegisterSchemaHints` overlays.
|
||||
|
||||
### Authoring
|
||||
|
||||
For every curated tool:
|
||||
|
||||
1. Edit `metadata/<product>.json` for safety/interface/gates/parameters.
|
||||
2. Edit `selection/<product>.json` for selection prose (`reviewed: true`,
|
||||
`review_reason`, `source_refs`).
|
||||
3. Run `make generate-schema`. Do not hand-edit generated
|
||||
`schema_agent_metadata/` or `schema_catalog/`.
|
||||
1. Declare safety/interface/parameters/selection on the owning leaf
|
||||
(`DeclareLeafMetadata` / `Shortcut.Contract` / `contract.ParamDecl`) and product routing
|
||||
via `ProductDecl` when needed.
|
||||
2. Run `make generate-schema` (param aliases + assembly determinism). Do not
|
||||
create or commit `schema_catalog/` or Schema meta-index fixtures.
|
||||
|
||||
### Pull live MCP descriptions (personal token)
|
||||
|
||||
Pinned `internal/cli/schema_mcp_metadata.json` is a sanitized baseline. Prefer
|
||||
live Schema from a logged-in personal session:
|
||||
Schema delivery no longer embeds a pinned MCP JSON. Prefer live Schema from a
|
||||
logged-in personal session when reviewing interface facts before declaring them
|
||||
on the leaf:
|
||||
|
||||
```bash
|
||||
dws auth status # token_valid should be true
|
||||
dws cache refresh # refresh discovery / tools cache
|
||||
dws cache refresh # deprecated no-op: prints a retirement notice (discovery cache is gone; refreshes nothing)
|
||||
dws schema <mcp-canonical> -f json
|
||||
# or CLI path: dws schema --cli-path "drive copy" -f json
|
||||
```
|
||||
|
||||
Resolve MCP identity via `interface_ref` when CLI canonical ≠ MCP path
|
||||
Resolve MCP identity via declared `interface_ref` when CLI canonical ≠ MCP path
|
||||
(example: CLI `drive.copy_document` → live `doc.copy_document`). On pull
|
||||
failure, fall back to Skill + Cobra Help + pinned MCP, and record evidence
|
||||
failure, fall back to Skill + Cobra Help, and record evidence
|
||||
(for example `live-dws-schema:<path>#FAILED`). Never print or commit tokens.
|
||||
`make fetch-mcp-metadata` writes an optional diagnostic dump under `artifacts/`
|
||||
only — do not commit it as a Schema pin.
|
||||
|
||||
Precedence when sources disagree: **Runtime/Cobra > live MCP > pinned MCP >
|
||||
Precedence when sources disagree: **Runtime/Cobra / leaf Contract > live MCP >
|
||||
Skill (evidence only)**.
|
||||
|
||||
### Parallel product agents
|
||||
@@ -242,9 +323,9 @@ Split work by product groups. Each agent must:
|
||||
|
||||
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
|
||||
for its tools.
|
||||
- Hand-write selection + metadata; forbid wholesale JSON merges from review
|
||||
dumps.
|
||||
- Edit only its `metadata/<product>.json` and `selection/<product>.json`.
|
||||
- Hand-write selection prose and leaf Contract / ProductDecl declarations;
|
||||
forbid wholesale JSON merges from review dumps.
|
||||
- Edit only its product’s leaf declarations (and `ProductDecl` when needed).
|
||||
- **Never** `git checkout` unrelated product files to “clean scope”.
|
||||
|
||||
### Regenerate and gates
|
||||
@@ -255,23 +336,24 @@ make generate-schema
|
||||
go test ./internal/app -run '^TestSheetFinalSchemaConfirmationMatchesRuntimeGuards$' -count=1
|
||||
```
|
||||
|
||||
`check-runtime-confirmation-truth.sh` compares live ContractFinal.Safety with the assembled ToolSpec `confirmation=user_required` and probes the runtime gate.
|
||||
`schema_hints/` must stay absent.
|
||||
|
||||
Example rules (fail generation otherwise):
|
||||
|
||||
- At most two examples per tool; no `--yes` in stored examples.
|
||||
- Examples must match live Cobra argv (path, flags, required groups).
|
||||
- No shell comments in examples.
|
||||
|
||||
After generation, spot-check Catalog: selection provenance is
|
||||
`reviewed_explicit` from `selection/`, and `user_required` count equals
|
||||
metadata `runtime_gate != none`.
|
||||
After generation, spot-check Catalog: selection and safety/interface
|
||||
provenance are `contract_final` from ProductDecl / leaf declarations
|
||||
(`user_required` must match Runtime confirmation gates).
|
||||
|
||||
`make generate-schema` is a full deterministic snapshot rebuild, not an
|
||||
incremental patch over the previous Catalog. It rereads every reviewed input,
|
||||
removes stale generated product metadata, and rewrites the exact metadata and
|
||||
Catalog projections. Incremental work happens only when an Agent or human
|
||||
edits selected `metadata/` or `selection/` entries; the next publication still
|
||||
recomputes all outputs. Generated files must never be read back as merge input,
|
||||
and byte guards fail generation if it changes the hint inputs or CommandRegistry.
|
||||
`make generate-schema` refreshes `param_aliases_generated.go` and runs
|
||||
assembly determinism (`check-schema-assembly.sh`). It does not rewrite a
|
||||
committed Catalog as delivery authority — runtime reassembles from
|
||||
declarations. Byte guards fail if generation mutates parameter-concept
|
||||
inputs; policy fails if the retired `schema_command_registry/` reappears.
|
||||
|
||||
Selection prose may choose a more or less restrictive recommendation. It cannot
|
||||
create a Cobra command or flag, change parameter facts, invent an
|
||||
@@ -317,7 +399,7 @@ proves natural-language understanding.
|
||||
|
||||
Semantic selection is an explicit opt-in live-model check. Run the smoke set
|
||||
(one positive and one negative scenario per product) with
|
||||
`DWS_AGENT_SELECTION_LIVE=1 ARK_API_KEY=... ARK_BASE_URL=... ARK_MODEL=... go test ./internal/app -run TestManualAgentSelectionArkLive -count=1`.
|
||||
`DWS_AGENT_SELECTION_LIVE=1 ARK_API_KEY=... ARK_BASE_URL=... ARK_MODEL=... go test ./internal/app -run TestAgentSelectionArkLive -count=1`.
|
||||
Add `DWS_AGENT_SELECTION_FULL=1` to evaluate every committed tool scenario, or
|
||||
set `DWS_AGENT_SELECTION_CASES` to comma-separated fixture case IDs. Normal CI
|
||||
never calls a model; its blockers remain the reproducible fixture, binding,
|
||||
@@ -341,14 +423,21 @@ Preserve all candidates and the selected source in provenance, and fail
|
||||
same-precedence conflicts rather than silently merging them.
|
||||
|
||||
`required` is the exception. Cobra `MarkFlagRequired` is a hard floor: the
|
||||
final Agent projection must keep `required=true` and cannot be lowered by
|
||||
manual/hint overlays. Overlays may still raise an optional flag to required.
|
||||
`cli_required` continues to mirror the executable Cobra marker.
|
||||
final Agent projection must keep `required=true` and cannot be lowered by a
|
||||
lower-precedence source. A higher-precedence declaration may still raise an
|
||||
optional flag to required. `cli_required` continues to mirror the executable
|
||||
Cobra marker.
|
||||
|
||||
For command text, reviewed `ToolSchemaHint` wins first, then command-specific
|
||||
Cobra Help, then MCP metadata. Generic RPC prose may remain an unselected
|
||||
provenance candidate (and parameter-level `interface_description`); it must not
|
||||
overwrite a specialized leaf's title or description.
|
||||
For command-level description: **declare required, delivery Long may win**.
|
||||
`ContractDecl.Description` is mandatory at construction (declaration evidence).
|
||||
Catalog delivery prefers Cobra Long when present (provenance `cobra_help`,
|
||||
resolution `cobra_help_preferred`); without Long, the declared Description is
|
||||
delivered as `contract_final`. Title keeps declared ContractDecl /
|
||||
ContractFinal first, then Cobra Short, then MCP metadata. This is one authority
|
||||
chain with an explicit delivery preference — not two competing sources.
|
||||
Generic RPC prose may remain an unselected provenance candidate (and
|
||||
parameter-level `interface_description`); it must not overwrite a specialized
|
||||
leaf's title or description.
|
||||
|
||||
For every delivered `ToolSpec` and `ParameterSpec` field, the provenance
|
||||
winner value must exactly equal the delivered value. Checking only source,
|
||||
|
||||
@@ -6,6 +6,48 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
|
||||
- **MCP service review retired** — deletes `schema_mcp_service_review.json` and removes its policy jq / outputguard / test disposition gate (`notify` → `out_of_surface`, snapshot hash pin). No replacement ledger.
|
||||
- **Hints retired; ContractDecl is the leaf Schema source** (#830) — `schema_hints/`, Manual/Schema hint overlays, and `schema_agent_metadata/` delivery are removed. Selection, safety, parameters, and interface facts declare on ProductDecl / leaf `Contract` (`corecmd.ContractDecl` + `contract.ParamDecl` / `Safety`). Authoring renamed `SchemaDecl` → `ContractDecl`; nested fields reuse `contract.*` directly.
|
||||
- **Contract package seam** (#830) — types / ProductDecl live under `internal/corecmd/contract` (DTO only). Annotate writers live in `internal/corecmd/runtimeannotate`; Cobra-keyed ContractFinal store + Register live in `internal/corecmd/contractfinal`; homology gates in `internal/cli/homology`. All packages import `corecmd/*` directly; the former `cli/runtimeannotate` / `cli/contractfinal` shim packages are removed, and the `cli` root keeps only package-local aliases (`runtime_schema_seam.go`). Catalog/`ResolveMeta` stay on the `cli` delivery root. `internal/corecmd` must not import any `internal/cli` package.
|
||||
- **CommandMeta cache for ResolveMeta** — production `ResolveMeta` / leaf `--help` Safety project from the runtime-assembled `SchemaRegistry` into a `map[cli_path]CommandMeta` installed during `deliverySchemaCatalog` sync.Once. Steady-state lookups are O(1); full Catalog wire maps stay deferred. Registry `Source` stamps `runtime-assembled`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Unified command safety and Shortcut runtime (H0)** — Shortcut leaves now execute through `corecmd.New`, sharing the same typed Safety confirmation gate as Leaf commands. EOF / closed stdin returns `confirmation_required`, and interactive `no` returns the existing non-zero cancellation validation error instead of reporting success for an operation that did not run. Pass `--yes` or `--dry-run` to skip the prompt.
|
||||
- **Constraint "provided" for `at_least_one` / `exactly_one` (H0)** — a flag set to an empty string (`--flag ""`) no longer counts as provided; previously bare Cobra `Changed` satisfied the constraint. Pass a non-blank value for a member of the group.
|
||||
- **Chat media download JSON compatibility** — `dws chat message download-media --format json` once again returns a clean `{success, downloadUrl, output}` result after the file is saved, preserving the temporary URL and resolved local path without progress text corrupting JSON stdout.
|
||||
|
||||
## [1.0.56] - 2026-08-04
|
||||
|
||||
This stable release promotes the fully delivered `v1.0.56-beta.4` baseline.
|
||||
It includes PR #852's resilient multipart Drive download implementation,
|
||||
together with the v1.0.56 beta-line command, Schema, Skill, and runtime
|
||||
improvements already validated through the prerelease channel.
|
||||
|
||||
### Added
|
||||
|
||||
- **Resilient multipart Drive downloads** (#852) — `drive download` and
|
||||
`drive download-version` support parallel chunk transfer, Range probing,
|
||||
fingerprint-validated checkpoint resume, automatic 401/403 credential
|
||||
refresh, and graceful interruption with checkpoint preservation.
|
||||
|
||||
## [1.0.56-beta.4] - 2026-08-04
|
||||
|
||||
This beta adds PR #852 on top of v1.0.56-beta.3. It makes Drive downloads
|
||||
resilient for large files through parallel transfer, validated resumable
|
||||
checkpoints, and automatic credential refresh.
|
||||
|
||||
### Added
|
||||
|
||||
- **Multipart Drive downloads** (#852) — adds `--part-size`, `--parallel`, and
|
||||
`--no-resume` to `drive download` and `drive download-version`. Files above
|
||||
the part-size threshold use a Range probe and parallel chunks, resume from a
|
||||
fingerprint-validated checkpoint, refresh credentials on 401/403, and keep
|
||||
the checkpoint when Ctrl+C interrupts a transfer.
|
||||
|
||||
## [1.0.56-beta.3] - 2026-08-03
|
||||
|
||||
This beta adds PRs #846 and #851 on top of v1.0.56-beta.2. It adds a
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.56-beta.3"
|
||||
version "1.0.56-beta.4"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "5d35bb3fca7883a4ee51e1561aefd6b954b104313359a7c05b30a333ea41e749"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "5a94069a3ab2c811d915639bbfd9ff17035b77501d5f9070c0b540ffe525a41b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "e9cbc1c647f3ea703e1b93264c0e7d92871cfabe26fef26fbe17b9dff4b80c0f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "7c475841ea871d204f9f6efc7d6e5378cf19db4541aeaa8a27d1387fa6f2a1f1"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-skills.zip"
|
||||
sha256 "b29b35345613bc9260ae37578eb982472591c9dc548b02176b5b9ba0bdc431f2"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
|
||||
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.55"
|
||||
version "1.0.56"
|
||||
license "Apache-2.0"
|
||||
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-arm64.tar.gz"
|
||||
sha256 "dd753bbd051e5dd007cf433b8aa211c4a221dd73dfcb0b3783fa924d09f12351"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-arm64.tar.gz"
|
||||
sha256 "5c6003fe484aa36cc00820a574186652467b9d075f19c159cf807e57590256ba"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-amd64.tar.gz"
|
||||
sha256 "f465eb7ac38a8a84eac4eb821fd15424bfc6f6245a60fa695ba97a639970dd77"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-amd64.tar.gz"
|
||||
sha256 "969b005a10682c2a1a828fa112165b5b0cd8ceeed8d22110ef7f39402cc36804"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-arm64.tar.gz"
|
||||
sha256 "5961be0fd551ec8e69b6fff2b1609f73486f7e6c3ffe8eb4bb99fa1ed691b401"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-arm64.tar.gz"
|
||||
sha256 "530c5ea7ddc7de320d9c2471fbd33752a723d00c9665f49321c7580e8392c756"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-amd64.tar.gz"
|
||||
sha256 "051ba404a5f6a8fb15def0e0f5d9d273cf9d63f881df2fffe159f2c4ea3366e7"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-amd64.tar.gz"
|
||||
sha256 "675fa42727ac9a549c6710b82e1980cd0f795363d71d5116a4e69771b7c5470e"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-skills.zip"
|
||||
sha256 "bd35f674f184001f5a03c7b5fa6029ebcda54f0054e15cd608b5b5e213ce2d05"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-skills.zip"
|
||||
sha256 "3d57794e4660a089209ce3962571d16ca0d46141e973c9993257a301cce0e097"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -5,10 +5,12 @@ PUBLISH ?= 0
|
||||
YES ?= 0
|
||||
DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp
|
||||
POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
|
||||
SCHEMA_CATALOG_OUTPUT ?= artifacts/schema_catalog
|
||||
SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
|
||||
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -34,9 +36,8 @@ help:
|
||||
@printf " make cli-smoke - Verify help for every public top-level command\n"
|
||||
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
|
||||
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
|
||||
@printf " make generate-schema - Regenerate embedded Agent metadata and the release Catalog\n"
|
||||
@printf " make generate-schema-agent-metadata - Regenerate versioned Agent metadata\n"
|
||||
@printf " make generate-schema-catalog - Regenerate the embedded release Catalog\n"
|
||||
@printf " make generate-schema - Refresh param_aliases + verify Schema assembly determinism\n"
|
||||
@printf " make generate-schema-catalog - Optional assembled Catalog dump under artifacts/ (not a delivery step)\n"
|
||||
@printf " make package - Build all release artifacts locally\n"
|
||||
@printf " make changelog-pre VERSION=vX.Y.Z-beta.N - Prepare prerelease notes\n"
|
||||
@printf " make changelog-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Prepare stable notes\n"
|
||||
@@ -86,7 +87,6 @@ policy: test-auth-legacy-compat
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
|
||||
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
|
||||
@@ -133,26 +133,26 @@ mock-mcp-smoke:
|
||||
$(GO) test -v -count=1 -run '^(TestHTTPClientEndToEnd|TestStdioClientEndToEnd)$$' ./internal/transport
|
||||
|
||||
test-schema-agent-examples:
|
||||
DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestManualAgentExamplesDryRun$$'
|
||||
DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestAgentExamplesDryRun$$'
|
||||
|
||||
# generate-schema refreshes param_aliases_generated.go and verifies that
|
||||
# ResolveSchemaBuild assembly is deterministic. Catalog is runtime-assembled
|
||||
# (声明即 Catalog); cmd_schema_catalog is not a committed delivery step.
|
||||
# schema_agent_metadata/ and schema_hints/ must stay absent.
|
||||
generate-schema:
|
||||
@set -e; \
|
||||
registry_guard=$$(mktemp -d); \
|
||||
concepts_guard=$$(mktemp); \
|
||||
concepts_schema_guard=$$(mktemp); \
|
||||
metadata_guard=$$(mktemp -d); \
|
||||
selection_guard=$$(mktemp -d); \
|
||||
trap 'rm -rf "$$registry_guard" "$$concepts_guard" "$$concepts_schema_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
|
||||
cp -R internal/cli/schema_command_registry/ "$$registry_guard/"; \
|
||||
trap 'rm -rf "$$concepts_guard" "$$concepts_schema_guard"' EXIT HUP INT TERM; \
|
||||
cp internal/cli/param_concepts.json "$$concepts_guard"; \
|
||||
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
|
||||
cp -R internal/cli/schema_hints/metadata/. "$$metadata_guard/"; \
|
||||
cp -R internal/cli/schema_hints/selection/. "$$selection_guard/"; \
|
||||
$(GO) generate ./internal/cli; \
|
||||
diff -qr internal/cli/schema_command_registry "$$registry_guard" >/dev/null || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry/' >&2; \
|
||||
rm -rf internal/cli/schema_agent_metadata internal/cli/schema_agent_metadata_audit.json; \
|
||||
rm -f internal/cli/schema_meta_index.json; \
|
||||
if [ -e internal/cli/schema_command_registry ]; then \
|
||||
printf '%s\n' 'retired schema_command_registry/ must not reappear after generation' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
fi; \
|
||||
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
|
||||
exit 1; \
|
||||
@@ -161,37 +161,27 @@ generate-schema:
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
|
||||
if [ -e internal/cli/schema_hints ]; then \
|
||||
printf '%s\n' 'retired schema_hints/ must not reappear after generation' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
|
||||
fi; \
|
||||
if [ -e internal/cli/schema_meta_index.json ]; then \
|
||||
printf '%s\n' 'retired schema_meta_index.json must not remain after generation' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
diff -qr internal/cli/schema_hints/metadata "$$metadata_guard" >/dev/null || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/metadata' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
diff -qr internal/cli/schema_hints/selection "$$selection_guard" >/dev/null || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/selection' >&2; \
|
||||
exit 1; \
|
||||
}
|
||||
|
||||
generate-schema-agent-metadata:
|
||||
$(GO) run ./internal/generator/cmd_schema_agent_metadata \
|
||||
-root . \
|
||||
-registry internal/cli/schema_command_registry \
|
||||
-output-dir internal/cli/schema_agent_metadata \
|
||||
-audit-output internal/cli/schema_agent_metadata_audit.json
|
||||
fi; \
|
||||
./scripts/policy/check-schema-assembly.sh
|
||||
|
||||
# Optional local/CI dump of an assembled Catalog under artifacts/ by default.
|
||||
# Override SCHEMA_CATALOG_OUTPUT and SCHEMA_META_INDEX_OUTPUT as needed. This
|
||||
# is not a go:generate or production delivery step.
|
||||
generate-schema-catalog:
|
||||
$(GO) run -a ./internal/generator/cmd_schema_catalog \
|
||||
-root . \
|
||||
-output internal/cli/schema_catalog
|
||||
-output "$(SCHEMA_CATALOG_OUTPUT)" \
|
||||
-meta-index "$(SCHEMA_META_INDEX_OUTPUT)"
|
||||
|
||||
fetch-mcp-metadata:
|
||||
@printf ' %sRefreshing MCP metadata from live server%s\n' "$(COLOR_RUN)" "$(COLOR_RESET)"
|
||||
@printf ' %sFetching diagnostic MCP dump (not a Schema pin)%s\n' "$(COLOR_RUN)" "$(COLOR_RESET)"
|
||||
@./scripts/dev/fetch_mcp_metadata.sh
|
||||
|
||||
package:
|
||||
|
||||
@@ -70,17 +70,17 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **multi** (default) | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **mono** (legacy) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** All product-scoped skills pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
> Installs and upgrades default to `multi`. `mono` remains available via `DWS_SKILL_MODE=mono` or `dws skill setup --mode mono`. File issues if you hit problems.
|
||||
|
||||
How to pick:
|
||||
|
||||
- **Quick install** (one-liner above): non-interactive, installs `mono`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
|
||||
- **Quick install** (one-liner above): non-interactive, installs `multi`.
|
||||
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) multi 2) mono` (default 1).
|
||||
- **Override via env**: `DWS_SKILL_MODE=mono curl -fsSL ... | sh`.
|
||||
- **Switch later**: `dws skill setup --mode mono` (or `--mode multi`) — re-run any time.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -393,19 +393,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
|
||||
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), legacy.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. Default layout.
|
||||
|
||||
Shared reviewed inputs for Schema generation live separately under `internal/cli/schema_hints/`. They are not Agent Skills and are excluded from binaries and release skill bundles.
|
||||
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
```bash
|
||||
# Install skills into current project (defaults to mono)
|
||||
# Install skills into current project (defaults to multi; DWS_SKILL_MODE=mono switches back)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
|
||||
> `install.sh` installs under `$HOME/.agents/skills/` (global; multi layout is per-product siblings, mono is the `dws/` subdirectory); `install-skills.sh` installs under `./.agents/skills/` (current project).
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
@@ -719,7 +719,7 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
|
||||
<summary>Coming soon</summary>
|
||||
|
||||
- `conference` (video meetings)
|
||||
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
|
||||
- Multi-skill mode (default) — per-product skills under `skills/multi/`; installs and upgrades default to it, `dws skill setup --mode mono` switches back
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+13
-13
@@ -70,17 +70,17 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **multi**(默认) | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **mono**(legacy) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
> 安装与升级默认均为 multi。mono 仍可通过 `DWS_SKILL_MODE=mono` 或 `dws skill setup --mode mono` 使用。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
|
||||
- **快速安装**(上方一行 curl):非交互,默认装 `multi`。
|
||||
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) multi 2) mono` 选项(默认 1)。
|
||||
- **环境变量覆盖**:`DWS_SKILL_MODE=mono curl -fsSL ... | sh`。
|
||||
- **装完之后再切换**:`dws skill setup --mode mono`(或 `--mode multi`),随时重跑都行。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -387,19 +387,19 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),legacy。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。默认布局。
|
||||
|
||||
Schema 生成共享的 reviewed 输入单独位于 `internal/cli/schema_hints/`。它们不是 Agent Skill,也不会进入二进制或发布 skill 包。
|
||||
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
```bash
|
||||
# 安装 skills 到当前项目(默认 mono)
|
||||
# 安装 skills 到当前项目(默认 multi;DWS_SKILL_MODE=mono 可切回)
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
|
||||
> `install.sh` 安装到 `$HOME/.agents/skills/`(全局,multi 为按产品平铺,mono 为 `dws/` 子目录);`install-skills.sh` 安装到 `./.agents/skills/`(当前项目)。
|
||||
>
|
||||
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
|
||||
|
||||
@@ -708,7 +708,7 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
|
||||
<summary>即将推出</summary>
|
||||
|
||||
- `conference`(视频会议)
|
||||
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
|
||||
- 多 skill 模式(默认)— 每产品一个独立 skill,位于 `skills/multi/`,安装与升级默认启用;`dws skill setup --mode mono` 可切回单 skill
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+134
-8
@@ -127,6 +127,15 @@ function installSkillsToHomes(skillRoot) {
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
// Mutual exclusion: remove multi leftovers before laying down mono.
|
||||
// Directories only — a stray file named dingtalk-x.md must survive.
|
||||
if (fs.existsSync(baseDir)) {
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && (entry.name.startsWith("dingtalk-") || entry.name === "dws-shared")) {
|
||||
fs.rmSync(path.join(baseDir, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
const destDir = path.join(baseDir, "dws");
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
copyChildren(skillRoot, destDir);
|
||||
@@ -138,23 +147,122 @@ function installSkillsToHomes(skillRoot) {
|
||||
}
|
||||
}
|
||||
|
||||
// multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and
|
||||
// Test-MultiTreeHasSkills in scripts/install.ps1: true only when the multi
|
||||
// bundle carries at least one product skill (a subdir with SKILL.md). An
|
||||
// empty or corrupt multi/ tree must never select the multi branch nor refresh
|
||||
// the multi cache — installing it would wipe existing skills and lay down
|
||||
// nothing.
|
||||
function multiTreeHasSkills(dir) {
|
||||
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.some((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, "SKILL.md")));
|
||||
}
|
||||
|
||||
// installMultiSkillsToHomes mirrors installSkillsToHomes for the multi bundle:
|
||||
// every product skill becomes a sibling directory of the agent home. Mutual
|
||||
// exclusion: the mono leftover (dws/) and stale dingtalk-* skills not present
|
||||
// in the new bundle are removed first.
|
||||
function installMultiSkillsToHomes(multiRoot) {
|
||||
const homeDir = os.homedir();
|
||||
const skills = fs
|
||||
.readdirSync(multiRoot, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && fs.existsSync(path.join(multiRoot, e.name, "SKILL.md")))
|
||||
.map((e) => e.name);
|
||||
if (skills.length === 0) {
|
||||
throw new Error(`no product skills found under ${multiRoot}`);
|
||||
}
|
||||
const skillSet = new Set(skills);
|
||||
let installed = 0;
|
||||
|
||||
const installToBase = (baseDir) => {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
fs.rmSync(path.join(baseDir, "dws"), { recursive: true, force: true });
|
||||
for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
(entry.name.startsWith("dingtalk-") || entry.name === "dws-shared") &&
|
||||
!skillSet.has(entry.name)
|
||||
) {
|
||||
fs.rmSync(path.join(baseDir, entry.name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
for (const name of skills) {
|
||||
const destDir = path.join(baseDir, name);
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
copyChildren(path.join(multiRoot, name), destDir);
|
||||
}
|
||||
};
|
||||
|
||||
AGENT_DIRS.forEach((agentDir, index) => {
|
||||
const baseDir = path.join(homeDir, agentDir);
|
||||
const parentGate = path.dirname(baseDir);
|
||||
if (index > 0 && !fs.existsSync(parentGate)) {
|
||||
return;
|
||||
}
|
||||
installToBase(baseDir);
|
||||
installed += 1;
|
||||
});
|
||||
|
||||
if (installed === 0) {
|
||||
installToBase(path.join(homeDir, ".agents", "skills"));
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi)
|
||||
// wins; multi is the default. The --skill-mode flag accepts both the space
|
||||
// form (`--skill-mode mono`) and the equals form (`--skill-mode=mono`).
|
||||
function resolveSkillMode() {
|
||||
const raw = (process.env.DWS_SKILL_MODE || "").trim().toLowerCase();
|
||||
if (raw === "mono" || raw === "multi") {
|
||||
return raw;
|
||||
}
|
||||
if (raw !== "") {
|
||||
throw new Error(`invalid DWS_SKILL_MODE='${process.env.DWS_SKILL_MODE}'. Use 'mono' or 'multi'.`);
|
||||
}
|
||||
let fromFlag;
|
||||
const flagIndex = process.argv.indexOf("--skill-mode");
|
||||
if (flagIndex !== -1 && process.argv[flagIndex + 1]) {
|
||||
fromFlag = process.argv[flagIndex + 1];
|
||||
} else {
|
||||
const equalsArg = process.argv.find((arg) => arg.startsWith("--skill-mode="));
|
||||
if (equalsArg) {
|
||||
fromFlag = equalsArg.slice("--skill-mode=".length);
|
||||
}
|
||||
}
|
||||
if (fromFlag !== undefined) {
|
||||
const mode = fromFlag.trim().toLowerCase();
|
||||
if (mode === "mono" || mode === "multi") {
|
||||
return mode;
|
||||
}
|
||||
throw new Error(`invalid --skill-mode '${fromFlag}'. Use 'mono' or 'multi'.`);
|
||||
}
|
||||
return "multi";
|
||||
}
|
||||
|
||||
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
|
||||
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
|
||||
// can fall back to a user-local cache when --source is not provided. mono is
|
||||
// already installed into agent homes by installSkillsToHomes; the cache is
|
||||
// purely a source-of-truth for the setup command.
|
||||
// can fall back to a user-local cache when --source is not provided. A cache
|
||||
// is only refreshed when the new bundle actually carries that tree — an
|
||||
// empty/corrupt multi/ (or a missing mono tree) must never wipe a previously
|
||||
// good cache.
|
||||
function cacheUserSkills(extractedSkillsRoot) {
|
||||
const cacheBase = path.join(os.homedir(), ".dws", "skills");
|
||||
|
||||
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
|
||||
? path.join(extractedSkillsRoot, "mono")
|
||||
: extractedSkillsRoot;
|
||||
const monoCache = path.join(cacheBase, "mono");
|
||||
fs.rmSync(monoCache, { recursive: true, force: true });
|
||||
copyChildren(monoSource, monoCache);
|
||||
if (fs.existsSync(path.join(monoSource, "SKILL.md"))) {
|
||||
const monoCache = path.join(cacheBase, "mono");
|
||||
fs.rmSync(monoCache, { recursive: true, force: true });
|
||||
copyChildren(monoSource, monoCache);
|
||||
}
|
||||
|
||||
const multiSource = path.join(extractedSkillsRoot, "multi");
|
||||
if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
|
||||
if (multiTreeHasSkills(multiSource)) {
|
||||
const multiCache = path.join(cacheBase, "multi");
|
||||
fs.rmSync(multiCache, { recursive: true, force: true });
|
||||
copyChildren(multiSource, multiCache);
|
||||
@@ -191,7 +299,25 @@ function main() {
|
||||
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
|
||||
? path.join(skillsStaging, "mono")
|
||||
: skillsStaging;
|
||||
installSkillsToHomes(monoRoot);
|
||||
// A mono install requires an actual SKILL.md at the root of monoRoot. On a
|
||||
// multi-only zip monoRoot would degrade to the staging root and copy the
|
||||
// whole bundle (multi/ included) into a dws/ directory — skip instead.
|
||||
const monoHasSkill = fs.existsSync(path.join(monoRoot, "SKILL.md"));
|
||||
const multiRoot = path.join(skillsStaging, "multi");
|
||||
const skillMode = resolveSkillMode();
|
||||
if (skillMode === "multi" && multiTreeHasSkills(multiRoot)) {
|
||||
console.log(`Skill mode: multi — installing per-product skills`);
|
||||
installMultiSkillsToHomes(multiRoot);
|
||||
} else {
|
||||
if (skillMode === "multi") {
|
||||
console.log("multi skill tree not found or empty in bundle; falling back to mono.");
|
||||
}
|
||||
if (monoHasSkill) {
|
||||
installSkillsToHomes(monoRoot);
|
||||
} else {
|
||||
console.log("mono skill tree not found in bundle; skipping skill install.");
|
||||
}
|
||||
}
|
||||
cacheUserSkills(skillsStaging);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
// Command fetch_mcp_metadata pulls tools/list from ALL live MCP server endpoints
|
||||
// and writes a refreshed schema_mcp_metadata.json. This is DWS's equivalent of
|
||||
// lark-cli's scripts/fetch_meta.py.
|
||||
// and writes a local diagnostic dump. It is NOT a Schema delivery refresh:
|
||||
// schema_mcp_metadata.json is retired; production Catalog assembles from
|
||||
// Contract/ParamDecl/Interface + Cobra only.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// dws auth login # ensure valid auth
|
||||
// make fetch-mcp-metadata # runs this tool
|
||||
// make fetch-mcp-metadata # writes artifacts/mcp_metadata_diagnostic.json
|
||||
//
|
||||
// The tool loads auth from the DWS keychain, iterates all 26 static server
|
||||
// endpoints (internal/syncdata.StaticServers), calls tools/list on each,
|
||||
// merges results, and writes schema_mcp_metadata.json.
|
||||
// The tool loads auth from the DWS keychain, iterates static server endpoints
|
||||
// (internal/syncdata.StaticServers), calls tools/list on each, merges results,
|
||||
// and writes the requested -output path (refuses the retired pin path).
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
|
||||
@@ -38,14 +40,15 @@ type toolLister interface {
|
||||
|
||||
// Injection points so run() is fully testable without network/keychain/exit.
|
||||
var (
|
||||
osExit = os.Exit
|
||||
getenv = os.Getenv
|
||||
loadTokenData = auth.LoadTokenDataKeychain
|
||||
staticServers = syncdata.StaticServers
|
||||
registrySource = cli.EmbeddedCommandRegistryMergedJSON
|
||||
listToolsTimeout = 30 * time.Second
|
||||
gitHeadPath = ".git/HEAD"
|
||||
newToolLister = func(token string) toolLister {
|
||||
osExit = os.Exit
|
||||
getenv = os.Getenv
|
||||
loadTokenData = auth.LoadTokenDataKeychain
|
||||
staticServers = syncdata.StaticServers
|
||||
registrySource = collectedIdentityInterfaceRefs
|
||||
collectIdentitySpecs = cli.CollectIdentitySpecs
|
||||
listToolsTimeout = 30 * time.Second
|
||||
gitHeadPath = ".git/HEAD"
|
||||
newToolLister = func(token string) toolLister {
|
||||
return transport.NewClient(&http.Client{Timeout: 60 * time.Second}).WithAuth(token, nil)
|
||||
}
|
||||
)
|
||||
@@ -57,10 +60,14 @@ func main() {
|
||||
func run(args []string, stderr io.Writer) int {
|
||||
flags := flag.NewFlagSet("fetch_mcp_metadata", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
output := flags.String("output", "internal/cli/schema_mcp_metadata.json", "output file path")
|
||||
output := flags.String("output", "artifacts/mcp_metadata_diagnostic.json", "diagnostic dump path (not a Schema pin)")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if retiredPinnedMCPMetadataPath(*output) {
|
||||
fmt.Fprintln(stderr, "fetch_mcp_metadata: refusing to write retired Schema pin internal/cli/schema_mcp_metadata.json")
|
||||
return 2
|
||||
}
|
||||
|
||||
token := resolveToken(stderr)
|
||||
if token == "" {
|
||||
@@ -74,11 +81,11 @@ func run(args []string, stderr io.Writer) int {
|
||||
servers := staticServers()
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: querying %d server endpoints\n", len(servers))
|
||||
|
||||
// Load CLI registry to build tool_name → interface_ref mapping.
|
||||
// Collect command identity to build tool_name → interface_ref mapping.
|
||||
registryMap := loadRegistryInterfaceRefs(stderr)
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: registry mapping: %d entries\n", len(registryMap))
|
||||
|
||||
// Load the previous schema_mcp_metadata.json to preserve hand-curated
|
||||
// Load a previous diagnostic dump (if any) to preserve hand-curated
|
||||
// cross-server interface_ref mappings that automated matching can't derive.
|
||||
prevData, prevErr := os.ReadFile(*output)
|
||||
prevTools := map[string]map[string]any{}
|
||||
@@ -310,48 +317,51 @@ func buildCrossServerRefs(prevTools map[string]map[string]any, registryMap map[s
|
||||
return index
|
||||
}
|
||||
|
||||
// loadRegistryInterfaceRefs loads the reviewed split CommandRegistry through
|
||||
// the cli package's reassembly API and builds a canonical_path →
|
||||
// {product_id, rpc_name} mapping for interface_ref injection.
|
||||
func loadRegistryInterfaceRefs(stderr io.Writer) map[string]map[string]string {
|
||||
data, err := registrySource()
|
||||
// collectedIdentityInterfaceRefs collects command identity from the live
|
||||
// command tree — the replacement for the retired reviewed CommandRegistry —
|
||||
// and derives the canonical_path → {product_id, rpc_name} mapping used for
|
||||
// interface_ref injection.
|
||||
func collectedIdentityInterfaceRefs() (map[string]map[string]string, error) {
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
specs, _, err := collectIdentitySpecs(root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot load registry: %v\n", err)
|
||||
return map[string]map[string]string{}
|
||||
return nil, fmt.Errorf("collect command identity: %w", err)
|
||||
}
|
||||
var reg struct {
|
||||
Products []struct {
|
||||
ID string `json:"id"`
|
||||
Tools []struct {
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
} `json:"tools"`
|
||||
} `json:"products"`
|
||||
}
|
||||
if err := json.Unmarshal(data, ®); err != nil {
|
||||
// 与读文件失败同等告警:静默返回空映射会让所有 live tool 被丢弃、
|
||||
// 产出 stub-only 快照且零提示(P1#1 的故障模式)。
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot parse registry: %v\n", err)
|
||||
return map[string]map[string]string{}
|
||||
}
|
||||
out := make(map[string]map[string]string)
|
||||
for _, prod := range reg.Products {
|
||||
for _, tool := range prod.Tools {
|
||||
cp := strings.TrimSpace(tool.CanonicalPath)
|
||||
if cp == "" || !strings.Contains(cp, ".") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(cp, ".", 2)
|
||||
out[cp] = map[string]string{
|
||||
"product_id": parts[0],
|
||||
"rpc_name": parts[1],
|
||||
}
|
||||
out := make(map[string]map[string]string, len(specs))
|
||||
for _, spec := range specs {
|
||||
cp := strings.TrimSpace(spec.CanonicalPath)
|
||||
if cp == "" || !strings.Contains(cp, ".") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(cp, ".", 2)
|
||||
out[cp] = map[string]string{
|
||||
"product_id": parts[0],
|
||||
"rpc_name": parts[1],
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadRegistryInterfaceRefs builds the canonical_path → interface_ref mapping
|
||||
// from the collected command identity. 与旧实现同等告警:静默返回空映射会让
|
||||
// 所有 live tool 被丢弃、产出 stub-only 快照且零提示(P1#1 的故障模式)。
|
||||
func loadRegistryInterfaceRefs(stderr io.Writer) map[string]map[string]string {
|
||||
refs, err := registrySource()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot collect command identity: %v\n", err)
|
||||
return map[string]map[string]string{}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func retiredPinnedMCPMetadataPath(path string) bool {
|
||||
cleaned := strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")
|
||||
return cleaned == "internal/cli/schema_mcp_metadata.json" ||
|
||||
strings.HasSuffix(cleaned, "/internal/cli/schema_mcp_metadata.json")
|
||||
}
|
||||
|
||||
// extractParams converts a JSON Schema inputSchema (from MCP tools/list) into
|
||||
// the flat param-name → metadata map used by schema_mcp_metadata.json.
|
||||
// the flat param-name → metadata map used by diagnostic dumps.
|
||||
func extractParams(inputSchema map[string]any) map[string]map[string]any {
|
||||
if inputSchema == nil {
|
||||
return nil
|
||||
|
||||
@@ -16,24 +16,66 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestLoadRegistryInterfaceRefsUsesSplitRegistry(t *testing.T) {
|
||||
func TestCrossPlatformCoverageLoadRegistryInterfaceRefsCollectsIdentity(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
refs := loadRegistryInterfaceRefs(&stderr)
|
||||
if len(refs) == 0 {
|
||||
t.Fatal("loadRegistryInterfaceRefs() returned no reviewed commands")
|
||||
t.Fatal("loadRegistryInterfaceRefs() returned no collected commands")
|
||||
}
|
||||
|
||||
got, ok := refs["calendar.list_calendars"]
|
||||
if !ok {
|
||||
t.Fatal("calendar.list_calendars missing from reassembled split registry")
|
||||
t.Fatal("calendar.list_calendars missing from collected command identity")
|
||||
}
|
||||
if got["product_id"] != "calendar" || got["rpc_name"] != "list_calendars" {
|
||||
t.Fatalf("calendar.list_calendars ref = %#v", got)
|
||||
}
|
||||
|
||||
direct, err := collectedIdentityInterfaceRefs()
|
||||
if err != nil {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs() error = %v", err)
|
||||
}
|
||||
if len(direct) == 0 || direct["calendar.list_calendars"]["rpc_name"] != "list_calendars" {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs() = %#v", direct["calendar.list_calendars"])
|
||||
}
|
||||
|
||||
prevRegistry := registrySource
|
||||
prevCollect := collectIdentitySpecs
|
||||
t.Cleanup(func() {
|
||||
registrySource = prevRegistry
|
||||
collectIdentitySpecs = prevCollect
|
||||
})
|
||||
registrySource = func() (map[string]map[string]string, error) {
|
||||
return nil, errors.New("collect boom")
|
||||
}
|
||||
stderr.Reset()
|
||||
if got := loadRegistryInterfaceRefs(&stderr); len(got) != 0 || !strings.Contains(stderr.String(), "cannot collect command identity") {
|
||||
t.Fatalf("loadRegistryInterfaceRefs error path = %#v stderr=%q", got, stderr.String())
|
||||
}
|
||||
|
||||
collectIdentitySpecs = func(*cobra.Command) ([]cli.CommandSpec, cli.IdentityCollectionReport, error) {
|
||||
return nil, cli.IdentityCollectionReport{}, errors.New("walk boom")
|
||||
}
|
||||
if _, err := collectedIdentityInterfaceRefs(); err == nil || !strings.Contains(err.Error(), "collect command identity") {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs wrap error = %v", err)
|
||||
}
|
||||
collectIdentitySpecs = func(*cobra.Command) ([]cli.CommandSpec, cli.IdentityCollectionReport, error) {
|
||||
return []cli.CommandSpec{
|
||||
{CanonicalPath: ""},
|
||||
{CanonicalPath: "nodot"},
|
||||
{CanonicalPath: "doc.create"},
|
||||
}, cli.IdentityCollectionReport{}, nil
|
||||
}
|
||||
gotRefs, err := collectedIdentityInterfaceRefs()
|
||||
if err != nil || len(gotRefs) != 1 || gotRefs["doc.create"]["rpc_name"] != "create" {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs skip = %#v err=%v", gotRefs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCrossServerRefs(t *testing.T) {
|
||||
@@ -91,8 +133,11 @@ func TestBuildCrossServerRefsSkipsMalformedRefs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunRefreshesCrossServerTools(t *testing.T) {
|
||||
registry := func() ([]byte, error) {
|
||||
return []byte(`{"version":1,"products":[{"id":"aitable","tools":[{"canonical_path":"aitable.advperm_enable"},{"canonical_path":"aitable.advperm_disable"}]}]}`), nil
|
||||
registry := func() (map[string]map[string]string, error) {
|
||||
return map[string]map[string]string{
|
||||
"aitable.advperm_enable": {"product_id": "aitable", "rpc_name": "advperm_enable"},
|
||||
"aitable.advperm_disable": {"product_id": "aitable", "rpc_name": "advperm_disable"},
|
||||
}, nil
|
||||
}
|
||||
servers := []syncdata.ServerInfo{{ID: "aitable-helper", Endpoint: "https://helper.example"}}
|
||||
lister := &fakeLister{
|
||||
@@ -147,8 +192,10 @@ func TestRunRefreshesCrossServerTools(t *testing.T) {
|
||||
// 跨 server 身份时,另一 server 上恰好同名的工具不得直连覆盖其元数据——
|
||||
// 数据源只能是评审身份指向的 live 工具。
|
||||
func TestRunCrossOwnedCanonicalIgnoresNameCoincidence(t *testing.T) {
|
||||
registry := func() ([]byte, error) {
|
||||
return []byte(`{"version":1,"products":[{"id":"aitable","tools":[{"canonical_path":"aitable.advperm_enable"}]}]}`), nil
|
||||
registry := func() (map[string]map[string]string, error) {
|
||||
return map[string]map[string]string{
|
||||
"aitable.advperm_enable": {"product_id": "aitable", "rpc_name": "advperm_enable"},
|
||||
}, nil
|
||||
}
|
||||
servers := []syncdata.ServerInfo{
|
||||
{ID: "aitable", Endpoint: "https://aitable.example"},
|
||||
@@ -298,7 +345,7 @@ func (f *fakeLister) ListTools(_ context.Context, endpoint string) (transport.To
|
||||
}
|
||||
|
||||
// stubDeps swaps every injection point for the duration of one test.
|
||||
func stubDeps(t *testing.T, token string, keychain func() (*auth.TokenData, error), servers []syncdata.ServerInfo, lister toolLister, registry func() ([]byte, error)) {
|
||||
func stubDeps(t *testing.T, token string, keychain func() (*auth.TokenData, error), servers []syncdata.ServerInfo, lister toolLister, registry func() (map[string]map[string]string, error)) {
|
||||
t.Helper()
|
||||
origGetenv, origLoad, origServers, origNew, origRegistry := getenv, loadTokenData, staticServers, newToolLister, registrySource
|
||||
t.Cleanup(func() {
|
||||
@@ -316,12 +363,32 @@ func stubDeps(t *testing.T, token string, keychain func() (*auth.TokenData, erro
|
||||
registrySource = registry
|
||||
}
|
||||
|
||||
func testRegistryJSON() ([]byte, error) {
|
||||
return []byte(`{"version":1,"products":[{"id":"doc","tools":[{"canonical_path":"doc.copy_document"},{"canonical_path":"doc.get_document"},{"canonical_path":"bad-entry"}]}]}`), nil
|
||||
func testRegistryRefs() (map[string]map[string]string, error) {
|
||||
return map[string]map[string]string{
|
||||
"doc.copy_document": {"product_id": "doc", "rpc_name": "copy_document"},
|
||||
"doc.get_document": {"product_id": "doc", "rpc_name": "get_document"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunRefusesRetiredPinnedMCPMetadataPath(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
code := run([]string{"--output", "internal/cli/schema_mcp_metadata.json"}, &stderr)
|
||||
if code != 2 {
|
||||
t.Fatalf("run(retired pin) = %d, want 2", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "refusing to write retired Schema pin") {
|
||||
t.Fatalf("stderr = %q, want retired-pin refusal", stderr.String())
|
||||
}
|
||||
if !retiredPinnedMCPMetadataPath("internal/cli/schema_mcp_metadata.json") ||
|
||||
!retiredPinnedMCPMetadataPath("/tmp/repo/internal/cli/schema_mcp_metadata.json") ||
|
||||
retiredPinnedMCPMetadataPath("artifacts/mcp_metadata_diagnostic.json") {
|
||||
t.Fatal("retiredPinnedMCPMetadataPath classification is incorrect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoTokenFails(t *testing.T) {
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) { return nil, errors.New("no keychain") }, nil, &fakeLister{}, testRegistryJSON)
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) { return nil, errors.New("no keychain") }, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if code := run(nil, &stderr); code != 1 {
|
||||
t.Fatalf("run() = %d, want 1", code)
|
||||
@@ -332,7 +399,7 @@ func TestRunNoTokenFails(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunInvalidFlagFails(t *testing.T) {
|
||||
stubDeps(t, "tok", nil, nil, &fakeLister{}, testRegistryJSON)
|
||||
stubDeps(t, "tok", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--nonexistent"}, &stderr); code != 2 {
|
||||
t.Fatalf("run() = %d, want 2", code)
|
||||
@@ -342,7 +409,7 @@ func TestRunInvalidFlagFails(t *testing.T) {
|
||||
func TestResolveTokenKeychainFallback(t *testing.T) {
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) {
|
||||
return &auth.TokenData{AccessToken: "kc-token"}, nil
|
||||
}, nil, &fakeLister{}, testRegistryJSON)
|
||||
}, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if got := resolveToken(&stderr); got != "kc-token" {
|
||||
t.Fatalf("resolveToken() = %q, want kc-token", got)
|
||||
@@ -353,14 +420,14 @@ func TestResolveTokenKeychainFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveTokenEmptyKeychainToken(t *testing.T) {
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) { return &auth.TokenData{}, nil }, nil, &fakeLister{}, testRegistryJSON)
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) { return &auth.TokenData{}, nil }, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if got := resolveToken(&stderr); got != "" {
|
||||
t.Fatalf("resolveToken() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWritesSnapshotWithHonestCoverage(t *testing.T) {
|
||||
func TestCrossPlatformCoverageRunWritesSnapshotWithHonestCoverage(t *testing.T) {
|
||||
servers := []syncdata.ServerInfo{
|
||||
{ID: "doc", Endpoint: "https://doc.example"},
|
||||
{ID: "sheet", Endpoint: "https://sheet.example"},
|
||||
@@ -383,7 +450,7 @@ func TestRunWritesSnapshotWithHonestCoverage(t *testing.T) {
|
||||
},
|
||||
errs: map[string]error{"https://sheet.example": errors.New("boom")},
|
||||
}
|
||||
stubDeps(t, "env-token", nil, servers, lister, testRegistryJSON)
|
||||
stubDeps(t, "env-token", nil, servers, lister, testRegistryRefs)
|
||||
|
||||
dir := t.TempDir()
|
||||
output := filepath.Join(dir, "snapshot.json")
|
||||
@@ -446,7 +513,7 @@ func TestRunWritesSnapshotWithHonestCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunIgnoresCorruptPreviousSnapshot(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
if err := os.WriteFile(output, []byte("{corrupt"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -457,32 +524,20 @@ func TestRunIgnoresCorruptPreviousSnapshot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRegistryLoadFailureStillWritesStublessSnapshot(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return nil, errors.New("no registry") })
|
||||
func TestCrossPlatformCoverageRunRegistryLoadFailureStillWritesStublessSnapshot(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() (map[string]map[string]string, error) { return nil, errors.New("no identity") })
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "cannot load registry") {
|
||||
t.Fatalf("stderr = %q, want registry warning", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUnparsableRegistryYieldsNoRefs(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return []byte("{bad"), nil })
|
||||
if refs := loadRegistryInterfaceRefs(&stderr); len(refs) != 0 {
|
||||
t.Fatalf("refs = %v, want empty for unparsable registry", refs)
|
||||
}
|
||||
// 解析失败必须有告警,不得静默产出空映射。
|
||||
if !strings.Contains(stderr.String(), "cannot parse registry") {
|
||||
t.Fatalf("stderr = %q, want parse warning", stderr.String())
|
||||
if !strings.Contains(stderr.String(), "cannot collect command identity") {
|
||||
t.Fatalf("stderr = %q, want identity collection warning", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWriteFailure(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
badPath := filepath.Join(t.TempDir(), "missing-dir", "snapshot.json")
|
||||
if code := run([]string{"--output", badPath}, &stderr); code != 1 {
|
||||
@@ -498,7 +553,7 @@ func TestWriteMetadataMarshalFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMainDelegatesToRun(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
origExit, origArgs := osExit, os.Args
|
||||
t.Cleanup(func() { osExit, os.Args = origExit, origArgs })
|
||||
exitCode := -1
|
||||
@@ -519,14 +574,14 @@ func TestExtractParamsNilAndNonObjectSchemas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewToolListerBuildsAuthedClient(t *testing.T) {
|
||||
func TestCrossPlatformCoverageNewToolListerBuildsAuthedClient(t *testing.T) {
|
||||
if lister := newToolLister("tok"); lister == nil {
|
||||
t.Fatal("newToolLister returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecordsSourceRevision(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON)
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
dir := t.TempDir()
|
||||
head := filepath.Join(dir, "HEAD")
|
||||
if err := os.WriteFile(head, []byte("ref: refs/heads/feature\n"), 0o600); err != nil {
|
||||
|
||||
+11
-5
@@ -1,6 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
`dws` is a Go CLI with a versioned, static command surface for DingTalk MCP capabilities. Cobra help serves humans; the embedded Command Catalog serves AI agents.
|
||||
`dws` is a Go CLI with a versioned, static command surface for DingTalk MCP capabilities. Cobra help serves humans; runtime-assembled Schema (`ResolveSchemaBuild`) serves AI agents.
|
||||
|
||||
## High-Level Flow
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
3. `internal/helpers` contains the main command handlers for all product surfaces (`dev`, `chat`, `calendar`, `contact`, `aitable`, etc.).
|
||||
4. `internal/executor` and `internal/transport` execute MCP JSON-RPC calls; `internal/output` formats responses.
|
||||
5. `internal/auth` manages login state, PAT tokens, and agent-code detection.
|
||||
6. Schema generation starts from the reviewed `CommandRegistry`, binds each identity to the exact current Cobra leaf, and then resolves typed constraints, sanitized MCP snapshots, Agent hints, and Skills into one `SchemaRegistry`. Startup and Schema queries do not call MCP `tools/list`.
|
||||
7. The embedded Catalog is a downstream release artifact and never backfills identity or participates in regeneration. Stable flag-to-interface property bindings come from the reviewed, content-addressed v3 manifest in `schema_parameter_bindings.json`; its exact active tuples, corrections, removals, and mapping exclusions are validated against the final bound `SchemaRegistry`. CLI `required` and constraints come from the resolved typed contract, while MCP `required` remains interface-only metadata.
|
||||
6. Schema assembly (`ResolveSchemaBuild`) starts from the reviewed `CommandRegistry`, binds each identity to the exact current Cobra leaf, and then resolves typed constraints, sanitized MCP snapshots, and leaf ContractFinal / ProductDecl into one `SchemaRegistry`. Startup and Schema queries do not call MCP `tools/list`. There is no generate-written Catalog delivery step.
|
||||
7. Production Catalog / `ResolveMeta` consume the lazily assembled registry via `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` / `deliverySchemaCatalog` (声明即 Catalog; lazy `sync.Once`). `ResolveMeta` projects Identity/Safety/Selection from that assembly into an in-process map cache — not a committed `schema_catalog/` or `schema_meta_index.*` fixture. Flag-to-interface property delivery is owned by leaf `ParamDecl.Property` (native annotations). `schema_parameter_mapping_ledger.go` holds reviewed `mapping_exclusions` / `removals` (the empty `schema_parameter_bindings.json` audit table is retired). CLI `required` and constraints come from the resolved typed contract, while MCP `required` remains interface-only metadata.
|
||||
8. Agent selection results are fixed in versioned review inputs. Every public tool has explicit use/avoid/example and interface disposition metadata; Skill references that are not current leaves require an explicit alias/group/stale/out-of-surface review instead of fuzzy runtime matching.
|
||||
|
||||
## Repository Structure
|
||||
@@ -19,8 +19,8 @@
|
||||
- `internal/app`: root command wiring, static utility commands, and plugin loading
|
||||
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
|
||||
- `internal/plugin`: versioned plugin manifest, hook, skill, and transport descriptor loading
|
||||
- `internal/cli`: embedded Agent Command Catalog, static schema query, and catalog contracts
|
||||
- `internal/generator`: deterministic Agent metadata and Command Catalog generators
|
||||
- `internal/cli`: Schema assembly, `dws schema` query, and catalog contracts
|
||||
- `internal/generator`: CI/determinism tools (`cmd_schema_catalog` dump) and param-alias generate
|
||||
- `internal/executor`: invocation dispatch and result handling
|
||||
- `internal/transport`: MCP HTTP client and request signing
|
||||
- `internal/auth`: login, token management, agent-code detection, identity
|
||||
@@ -30,6 +30,12 @@
|
||||
- `internal/security`: endpoint allowlist and domain trust
|
||||
- `internal/safety`: runtime safety checks (confirm prompts, dry-run guards)
|
||||
- `internal/cobracmd`: shared Cobra command builders
|
||||
- `internal/corecmd`: dispatch-agnostic leaf-command base — flag registration,
|
||||
alias/env/default value resolution, required and cross-flag constraint
|
||||
validation, Risk write confirmation, toolArgs assembly, Runtime Schema
|
||||
projection. Distinct from `internal/cobracmd` (generic tree helpers): it owns
|
||||
the declarative leaf contract (`corecmd.Spec`) that the LeafSpec framework is
|
||||
built on and that the Shortcut adapter projects into.
|
||||
- `internal/pat`: PAT (Personal Access Token) authorization flow
|
||||
- `internal/output`: response formatting (json, table, raw, pretty)
|
||||
- `internal/logging`: structured logging and argument sanitization
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# command 领域模型
|
||||
|
||||
本文档描述 `internal/corecmd` 包的领域模型——类型、概念及其关系。
|
||||
|
||||
## 核心模型图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ corecmd.Spec │
|
||||
│ (一个叶子命令的完整契约) │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─── CLI 表面 ───┐ ┌─── 参数声明 ───────────────────────────┐ │
|
||||
│ │ Use │ │ FlagSpec[] │ │
|
||||
│ │ Short │ │ ├─ Name / Kind / Default │ │
|
||||
│ │ Long │ │ ├─ Required / MarkRequired │ │
|
||||
│ │ Example │ │ ├─ Aliases[] / EnvVar (回退链) │ │
|
||||
│ └────────────────┘ │ ├─ Bind / Transform / OmitEmpty │ │
|
||||
│ │ └─ Enum / Format / SchemaDescription │ │
|
||||
│ │ │ │
|
||||
│ │ Constraint[] │ │
|
||||
│ │ ├─ at_least_one │ │
|
||||
│ │ ├─ exactly_one │ │
|
||||
│ │ └─ mutually_exclusive │ │
|
||||
│ │ │ │
|
||||
│ │ ConstParams map[string]any │ │
|
||||
│ └────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─── 安全模型 ──────────────────────────────────────────────────┐ │
|
||||
│ │ Safety contract.SafetySpec │ │
|
||||
│ │ ├─ Effect (read / write / destructive) │ │
|
||||
│ │ ├─ Risk (low / medium / high) │ │
|
||||
│ │ ├─ Confirmation (not_required / user_required) ──▶ 运行时门 │ │
|
||||
│ │ └─ Idempotency (idempotent / retryable / …) │ │
|
||||
│ │ │ │
|
||||
│ │ 四字段彼此独立;同一值同时供运行时与 Schema 使用 │ │
|
||||
│ │ ConfirmFirst: bool (只控制确认门顺序) │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─── Contract 声明 (Agent 可见的元数据) ────────────────────────┐ │
|
||||
│ │ ContractDecl │ │
|
||||
│ │ ├─ Title / Description │ │
|
||||
│ │ ├─ contract.DryRunSpec {PreviewKind, RemoteReads} │ │
|
||||
│ │ ├─ contract.InterfaceSpec {Mode, Availability, Reason, Ref}│ │
|
||||
│ │ ├─ contract.SelectionSpec {AgentSummary, UseWhen, AvoidWhen│ │
|
||||
│ │ │ Prerequisites, Tips, Examples} │ │
|
||||
│ │ ├─ contract.ToolIdentitySpec {ProductID, CanonicalPath, …} │ │
|
||||
│ │ └─ Positionals[] {Name, Type, Required, Variadic} │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─── 执行体 (恰好一个) ─────────────────────────────────────────┐ │
|
||||
│ │ Invoke(Ctx, toolArgs) ← #830 过渡:单步派发(目标 mcpbind)│ │
|
||||
│ │ Orchestrate(Ctx) ← #830 过渡:多步编排(目标 Handler)│ │
|
||||
│ │ RunE(cmd, args) ← 逃生舱:完全自定义 │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─── 钩子 ─────────────────────────────────────────────────────┐ │
|
||||
│ │ Validate(cmd, args) ← 条件式业务校验(约束表达不了的) │ │
|
||||
│ │ PostMount(cmd) ← 挂载收尾(设置 Args 等 cobra 属性) │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 构建与执行流
|
||||
|
||||
```
|
||||
corecmd.Spec ──── corecmd.New() ────▶ cobra.Command ──── 用户执行 ────▶ Ctx
|
||||
│ │ │
|
||||
构建时检查: 注册产物: 执行上下文:
|
||||
• validateDispatchDecl • Flags + Aliases • Str(name)
|
||||
• validateSafetySpec • Annotations (Schema) • Int(name)
|
||||
• validateContractDecl • Long (约束 help) • Bool(name)
|
||||
• RegisterFlags • RunE (管线) • StrSlice(name)
|
||||
• ValidateConstraintDecls • Changed(name)
|
||||
• embedContractIntoSchema • DryRun() / Yes()
|
||||
• AnnotateConstraints
|
||||
• PostMount
|
||||
```
|
||||
|
||||
## 领域概念
|
||||
|
||||
| 概念 | 类型 | 职责 |
|
||||
|------|------|------|
|
||||
| **corecmd.Spec** | struct | 一个命令的完整契约(声明 + 执行) |
|
||||
| **FlagSpec** | struct | 一个参数的注册、回退链、绑定规则 |
|
||||
| **Constraint** | struct | 参数间的关系约束 |
|
||||
| **Safety** | contract.SafetySpec | 运行时与 Schema 共用的安全契约 |
|
||||
| **ContractDecl** | struct | Agent 可见的完整工具规格声明 |
|
||||
| **contract.SelectionSpec** | struct | Agent 选择该工具的语义指引 |
|
||||
| **contract.InterfaceSpec** | struct | 工具的接口模式与可用性 |
|
||||
| **contract.DryRunSpec** | struct | dry-run 能力声明 |
|
||||
| **contract.ToolIdentitySpec** | struct | 工具在注册表中的身份标识 |
|
||||
| **contract.RuntimeSchemaPositional** | struct | 有序位置参数声明 |
|
||||
| **Ctx** | struct | 执行上下文(类型安全的 flag 读取) |
|
||||
| **New** | func | 统一构建器(`corecmd.Spec` → `*cobra.Command`) |
|
||||
|
||||
## SafetySpec
|
||||
|
||||
`corecmd.Spec.Safety` 使用 `corecmd/contract.SafetySpec`(无 cli 类型别名),没有 command 自定义 Risk/Safety 枚举,也没有 `SafetyDecl` 覆盖层:
|
||||
|
||||
```go
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write",
|
||||
Risk: "high",
|
||||
Confirmation: "user_required",
|
||||
Idempotency: "unknown",
|
||||
}
|
||||
```
|
||||
|
||||
- `Confirmation == "user_required"` 时执行确认;`--yes` 和 `--dry-run` 可跳过交互。
|
||||
- `Effect`、`Risk`、`Idempotency` 不参与确认决策,也不会改写 `Confirmation`。
|
||||
- 任意一个字段非空时,四个字段必须全部显式声明;构建时拒绝部分声明。
|
||||
- 完全空值仅作为历史只读默认,最终发布为 `read/low/not_required/idempotent`。
|
||||
|
||||
## FlagSpec 有效值回退链
|
||||
|
||||
框架统一的 flag 值解析顺序:
|
||||
|
||||
```
|
||||
显式主 flag (Changed)
|
||||
│ 空?
|
||||
▼
|
||||
隐藏别名 (Changed, 按声明序)
|
||||
│ 空?
|
||||
▼
|
||||
环境变量 (EnvVar)
|
||||
│ 空?
|
||||
▼
|
||||
注册默认值 (Default)
|
||||
│ 空?
|
||||
▼
|
||||
ArgDefault (兜底)
|
||||
```
|
||||
|
||||
各 Kind 的特殊行为:
|
||||
|
||||
| Kind | 入参条件 | 回退链 |
|
||||
|------|----------|--------|
|
||||
| KindString | 有效值非空(或 !OmitEmpty) | 完整参与 |
|
||||
| KindInt | 值 ≠ 0(putInt 语义) | 完整参与 |
|
||||
| KindBool | Changed 时入参(显式 false 也下发) | 不参与别名/env 回退 |
|
||||
| KindStringSlice | 存在非空元素 | 仅 Changed 的主 flag/alias |
|
||||
|
||||
## Constraint 约束
|
||||
|
||||
声明式跨 flag 关系,构建时校验合法性,运行时统一执行:
|
||||
|
||||
| Kind | 语义 | 错误文案示例 |
|
||||
|------|------|-------------|
|
||||
| `at_least_one` | 至少提供一个 | "请至少指定 --a、--b 之一" |
|
||||
| `exactly_one` | 恰好提供一个 | "请指定 --a、--b 之一" / "只能指定其一" |
|
||||
| `mutually_exclusive` | 最多提供一个 | "参数 --a、--b 互斥,只能指定其一" |
|
||||
|
||||
"是否提供"的判定复用有效值回退链(显式主 flag → 别名 → env),注册默认值不算作已提供。
|
||||
|
||||
## ContractDecl 子结构
|
||||
|
||||
### contract.SelectionSpec(Agent 选择指引)
|
||||
|
||||
```go
|
||||
contract.SelectionSpec{
|
||||
AgentSummary: "一句话描述工具做什么",
|
||||
UseWhen: []string{"在什么场景下应该选择这个工具"},
|
||||
AvoidWhen: []string{"什么场景不应该用,应该用什么替代"},
|
||||
Prerequisites: []string{"使用前提条件"},
|
||||
Tips: []string{"使用技巧"},
|
||||
Examples: []string{"dws dev app create --name Bot --dry-run"},
|
||||
}
|
||||
```
|
||||
|
||||
### contract.InterfaceSpec(接口模式)
|
||||
|
||||
```go
|
||||
contract.InterfaceSpec{
|
||||
Mode: "composite", // local / mcp / composite
|
||||
Availability: "available", // available / unavailable
|
||||
Reason: "...", // composite/unavailable 时的原因
|
||||
Ref: &contract.InterfaceRefSpec{ // mcp 时的 ref
|
||||
ProductID: "...",
|
||||
RPCName: "...",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### contract.DryRunSpec(dry-run 能力)
|
||||
|
||||
```go
|
||||
contract.DryRunSpec{
|
||||
PreviewKind: "invocation", // invocation / request / plan
|
||||
RemoteReads: false, // dry-run 时是否发起远端读
|
||||
}
|
||||
```
|
||||
|
||||
## 执行体三选一
|
||||
|
||||
| 执行体 | 适用场景 | 框架做了什么 |
|
||||
|--------|----------|-------------|
|
||||
| **Invoke** | #830 过渡单步派发(生产仍用;目标 mcpbind) | 框架完成 required→constraint→validate→buildArgs→confirm,传入装配好的 toolArgs |
|
||||
| **Orchestrate** | #830 过渡多步编排(生产仍用;目标 Handler) | 框架完成 required→constraint→validate→confirm,传入 Ctx 自行组装调用 |
|
||||
| **RunE** | 逃生舱 | 框架仍执行 Safety 确认,具体业务执行完全自定义 |
|
||||
|
||||
## 设计不变量
|
||||
|
||||
1. **一个 corecmd.Spec = 一个叶子命令的全部事实**
|
||||
2. **声明面绝不调用后端**——command 是 dispatch-agnostic
|
||||
3. **执行面绝不发明 CLI 表面**——业务 flag 必须在 Flags 声明
|
||||
4. **构建时拦截 > 运行时报错**——声明错误 panic 在注册阶段
|
||||
5. **SafetySpec 是单一事实源**——Confirmation 驱动运行时,其余字段原样进入 Schema
|
||||
6. **声明即 review**——代码中的 Schema 经 code review 后直接投影,不依赖外部 hint 文件
|
||||
@@ -0,0 +1,286 @@
|
||||
# 命令框架架构
|
||||
|
||||
本文档描述 `internal/corecmd` 统一命令框架的当前架构,面向框架使用者和维护者。
|
||||
|
||||
## 概览
|
||||
|
||||
```
|
||||
用户输入 → cobra 命令树 → corecmd.New() → 运行时管线 → 后端派发
|
||||
```
|
||||
|
||||
命令框架将 CLI 命令的**声明**与**执行**分离:
|
||||
|
||||
- **声明面** — 数据字段描述命令是什么(flag、约束、SafetySpec、Contract 元数据)
|
||||
- **执行面** — 钩子函数描述命令做什么(校验、派发、编排)
|
||||
|
||||
框架负责:flag 注册、有效值回退链、required/约束校验、SafetySpec 确认、toolArgs 装配、Agent Runtime Schema 投影。
|
||||
|
||||
## 核心类型
|
||||
|
||||
### corecmd.Spec
|
||||
|
||||
统一的类型化命令规格,是框架的核心数据结构:
|
||||
|
||||
```go
|
||||
type Spec struct {
|
||||
// 声明面
|
||||
Use string
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
Flags []FlagSpec
|
||||
Constraints []Constraint
|
||||
Safety contract.SafetySpec // 运行时与 Schema 的单一安全来源
|
||||
ConfirmFirst bool // 确认门先于参数校验
|
||||
ConstParams map[string]any
|
||||
Contract ContractDecl // 叶子 Contract 声明(非 Catalog Schema)
|
||||
|
||||
// 执行面(恰好一个;Invoke/Orchestrate 为 #830 过渡派发 API,目标 mcpbind+Handler)
|
||||
Invoke func(c *Ctx, toolArgs map[string]any) error // 过渡:单步
|
||||
Orchestrate func(c *Ctx) error // 过渡:多步
|
||||
RunE func(cmd *cobra.Command, args []string) error // 逃生舱
|
||||
|
||||
// 钩子
|
||||
Validate func(cmd *cobra.Command, args []string) error
|
||||
PostMount func(cmd *cobra.Command)
|
||||
}
|
||||
```
|
||||
|
||||
### SafetySpec(单一安全来源)
|
||||
|
||||
`Spec.Safety` 使用 `corecmd/contract.SafetySpec`:
|
||||
|
||||
| 字段 | 职责 |
|
||||
|------|------|
|
||||
| `Effect` | 操作影响:read / write / destructive |
|
||||
| `Risk` | 风险等级:low / medium / high |
|
||||
| `Confirmation` | 是否需要用户确认:not_required / user_required |
|
||||
| `Idempotency` | 幂等性:idempotent / retryable / non_idempotent / unknown |
|
||||
|
||||
四个字段彼此独立。框架只读取 `Confirmation` 决定运行时确认,其余字段原样发布到 Schema,不从一个字段机械推导另一个。非空 SafetySpec 必须一次声明完整:
|
||||
|
||||
```go
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write",
|
||||
Risk: "high",
|
||||
Confirmation: "user_required",
|
||||
Idempotency: "unknown",
|
||||
},
|
||||
```
|
||||
|
||||
完全空值保留历史只读默认 `read/low/not_required/idempotent`;不存在 Risk/Safety 枚举或覆盖优先级链。
|
||||
|
||||
### FlagSpec
|
||||
|
||||
声明一个 flag 的注册方式、有效值回退链、到 toolArgs 的绑定:
|
||||
|
||||
```go
|
||||
type FlagSpec struct {
|
||||
Name string // flag 名(kebab-case)
|
||||
Usage string // --help 文案
|
||||
Kind FlagKind // String / Int / Bool / StringSlice
|
||||
Default string // 注册默认值
|
||||
Required bool // 框架校验非空
|
||||
Aliases []string // 隐藏别名
|
||||
EnvVar string // 环境变量回退
|
||||
Bind string // toolArgs 键名(空则用 Name)
|
||||
Transform func(string) (any, error) // 值转换
|
||||
// ...更多字段见源码
|
||||
}
|
||||
```
|
||||
|
||||
### Constraint
|
||||
|
||||
跨 flag 关系约束:
|
||||
|
||||
```go
|
||||
type Constraint struct {
|
||||
Kind ConstraintKind // at_least_one / exactly_one / mutually_exclusive
|
||||
Flags []string
|
||||
}
|
||||
```
|
||||
|
||||
## 有效值回退链
|
||||
|
||||
flag 解析按以下顺序取值(先命中先生效):
|
||||
|
||||
```
|
||||
显式主 flag (Changed) → 隐藏别名 (Changed) → 环境变量 → 注册默认值
|
||||
│
|
||||
ArgDefault ←──┘ (兜底)
|
||||
```
|
||||
|
||||
- KindBool:仅 Changed 时生效,不参与回退链
|
||||
- KindStringSlice:仅主 flag / alias Changed 时生效,元素恒 TrimSpace
|
||||
- KindInt:非零才入 toolArgs(putInt 语义)
|
||||
|
||||
## 构建时流程
|
||||
|
||||
`corecmd.New(spec)` 执行以下构建时检查(失败则 panic):
|
||||
|
||||
1. **validateDispatchDecl** — 恰好一个执行体(Invoke/Orchestrate/RunE)
|
||||
2. **validateSafetySpec** — 非空 SafetySpec 的四个独立字段必须完整
|
||||
3. **validateContractDecl** — Contract 声明完整性(Description、AgentSummary、UseWhen、AvoidWhen、Examples、Interface)
|
||||
4. **RegisterFlags** — flag + alias 注册到 cobra
|
||||
5. **ValidateConstraintDecls** — 约束引用的 flag 必须存在
|
||||
6. **embedContractIntoSchema** — 投影到 dws.schema.* annotations
|
||||
7. **AnnotateConstraints** — 约束渲染到 --help
|
||||
8. **PostMount** — 调用方的挂载收尾钩子
|
||||
|
||||
## 运行时流程
|
||||
|
||||
生成的 `RunE` 按以下顺序执行:
|
||||
|
||||
```
|
||||
[ConfirmFirst? → ConfirmSafety] ← 可选:先确认后校验
|
||||
│
|
||||
▼
|
||||
ValidateRequired ← 有效值回退链校验
|
||||
│
|
||||
▼
|
||||
ValidateConstraints ← 互斥/至少一个/恰好一个
|
||||
│
|
||||
▼
|
||||
Validate hook ← 条件式业务校验(可选)
|
||||
│
|
||||
▼
|
||||
BuildArgs ← flag → toolArgs 装配
|
||||
│
|
||||
▼
|
||||
ConstParams 合并
|
||||
│
|
||||
▼
|
||||
[!ConfirmFirst? → ConfirmSafety] ← 默认顺序:校验后确认
|
||||
│
|
||||
▼
|
||||
Invoke(ctx, toolArgs) ← #830 过渡:单步派发
|
||||
或 Orchestrate(ctx) ← #830 过渡:多步编排
|
||||
```
|
||||
|
||||
## 消费方式
|
||||
|
||||
### LeafSpec(MCP 直连叶子命令)
|
||||
|
||||
```go
|
||||
func newDevAppCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
return NewLeafCommand(LeafSpec{
|
||||
Use: "create",
|
||||
Short: "创建开放平台企业内部应用",
|
||||
Tool: devAppCreateTool,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
ConfirmFirst: true,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "name", Usage: "应用名称 (必填)", Bind: "name",
|
||||
Trim: true, Required: true, RequiredHint: "--name 为必填"},
|
||||
},
|
||||
Contract: ContractDecl{
|
||||
Description: "创建开放平台企业内部应用",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "invocation"},
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "create then configure"},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建钉钉开放平台应用",
|
||||
UseWhen: []string{"需要新建企业内部应用"},
|
||||
AvoidWhen: []string{"应用已存在时用 update"},
|
||||
Examples: []string{`dws dev app create --name "Bot" --dry-run`},
|
||||
},
|
||||
},
|
||||
Call: devAppCall(runner),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
`NewLeafCommand` 经 `FromLeafSpec()` 归一为 `corecmd.Spec`,再交 `corecmd.New()` 构建。这是**完全托管模式**:声明 + 执行都归 command。
|
||||
|
||||
### 声明元数据模式(既有命令补 Contract)
|
||||
|
||||
执行体必须冻结时,用同一套 `LeafSpec` 词汇只声明元数据,写在命令字面量旁:
|
||||
|
||||
```go
|
||||
baseListCmd := &cobra.Command{
|
||||
Use: "list", Short: "获取 AI 表格列表",
|
||||
RunE: func(cmd *cobra.Command, args []string) error { /* 原执行体不动 */ },
|
||||
}
|
||||
DeclareLeafMetadata(baseListCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: ContractDecl{
|
||||
Description: "列出最近访问的 AI 表格 Base。",
|
||||
Interface: aitableMCPInterface("list_bases"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出最近访问的 AI 表格 Base。",
|
||||
UseWhen: []string{"只需浏览最近打开过的 Base 时"},
|
||||
AvoidWhen: []string{"按名称查找优先 base search"},
|
||||
Examples: []string{"dws aitable base list"},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`DeclareLeafMetadata` 调用 `corecmd.AttachContract` 挂 Safety+Contract;不注册 flag、不接管参数投影。可选 `Validate` 与 `ConfirmSafety` 同挂在 **RunE 包装器**内(不是 PreRunE)。当 `Safety.Confirmation=user_required` 时,用**同一份** SafetySpec 包一层 `ConfirmSafety`,保证执行门禁与 Catalog 同源;无 Validate 时确认推迟到 gated `CallTool`,成功返回却未确认则 fail-closed。迁移态入口;新命令仍应走 `NewLeafCommand`。
|
||||
|
||||
### 三档路径(当前可接受)
|
||||
|
||||
| 档 | 入口 | 说明 |
|
||||
|---|---|---|
|
||||
| **Tier1** | `corecmd.New` / `NewLeafCommand` | 完全托管:声明 + 执行都归框架 |
|
||||
| **Tier2** | `DeclareLeafMetadata` | helpers 迁移态;**Shortcut 也可采用,可接受** |
|
||||
| **Tier3** | 裸 Cobra | 应逐步收;新增裸叶需补声明或精确排除 |
|
||||
|
||||
长期展望(非当前硬要求):更多 Shortcut 可收敛到 mcpbind / 减少仅为参数装配的 `Execute`。**不要**把「Shortcut 必须去掉 Execute / 必须 mcpbind」当作当前门禁;也不要否定 Shortcut + `DeclareLeafMetadata`。
|
||||
|
||||
### Shortcut(智能快捷方式,已接入 live mount)
|
||||
|
||||
```go
|
||||
func mount(s Shortcut) *cobra.Command {
|
||||
return corecmd.New(FromShortcut(s))
|
||||
}
|
||||
|
||||
spec := FromShortcut(Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+demo",
|
||||
Risk: RiskHighWrite,
|
||||
Flags: []Flag{...},
|
||||
Execute: func(rt *RuntimeContext) error { ... },
|
||||
})
|
||||
```
|
||||
|
||||
Shortcut 当前仍保留自身的 `Risk`,adapter 只在边界将它展开成完整
|
||||
`contract.SafetySpec`;command/Leaf 不再保留该枚举。Shortcut 的 Cobra
|
||||
type/default/usage provenance 保持不变,command 统一补充 Required、Enum 和关系约束投影。
|
||||
需要补 Agent Schema 且执行体暂不迁入时,Shortcut 也可走 Tier2
|
||||
`DeclareLeafMetadata`(与 helpers 同一路径)。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 / 包 | 职责 |
|
||||
|------|------|
|
||||
| `internal/corecmd/corecmd.go` | 核心类型 + `New` 构建器 + 运行时管线 |
|
||||
| `internal/corecmd/contract_decl.go` | ContractDecl 载荷类型 + 声明完整性守卫 |
|
||||
| `internal/corecmd/contract/` | 契约 DTO(`SafetySpec` / `ParamDecl` / `ProductDecl` / `ContractFinalPayload`);**无** Cobra-keyed Final store |
|
||||
| `internal/corecmd/runtimeannotate/` | `AnnotateRuntime*` 写注解(框架侧;`cli` 薄 re-export) |
|
||||
| `internal/corecmd/contractfinal/` | ContractFinal Cobra store + `RegisterRuntimeContractFinal`(框架侧;`cli` 薄 re-export) |
|
||||
| `internal/cli/homology/` | flag/help/schema 同源门禁(`HOM-*`) |
|
||||
| `internal/helpers/leaf.go` | LeafSpec 门面:`NewLeafCommand`(完全托管)+ `DeclareLeafMetadata`(声明元数据) |
|
||||
| `internal/shortcut/adapter.go` | FromShortcut 完整映射与 Risk 兼容边界 |
|
||||
| `internal/shortcut/runner.go` | RuntimeContext;live mount 委托 `corecmd.New(FromShortcut(s))` |
|
||||
|
||||
## Schema 投影
|
||||
|
||||
声明即 review:代码中的 Contract 声明经过 code review 后直接投影为:
|
||||
|
||||
- **Agent Runtime Schema**(`dws.schema.*` Cobra annotations;经 `runtimeannotate` / ContractFinal 嵌入)
|
||||
- **运行时组装的 SchemaRegistry / Catalog ToolSpec wire**(`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`;`dws schema` / `--all` / 完整 leaf 载荷)
|
||||
- **CommandMeta 投影**(装配 Once 同步缓存 `map[cli_path]CommandMeta`;`ResolveMeta` / `SafetyForCLIPath` / leaf `--help` Safety 稳态 O(1) 读缓存,与 SchemaRegistry 同源)
|
||||
- **Dry-run Capabilities**(声明自动索引为 reviewed 能力)
|
||||
|
||||
生产权威是 leaf `ContractFinal` / `ProductDecl`(经 `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` 装配进 Catalog);`InstallBuildTimeAgentMetadataJSON` 仅用于 `cmd_schema_catalog` 的 CI/local dump inject,不是生产交付路径。`schema_agent_metadata/` 与 `schema_hints/` 已退役。不再需要外部 hint 文件维护 selection/metadata/dry-run 信息。Catalog/meta-index 路径不得提交。
|
||||
|
||||
## 设计原则
|
||||
|
||||
1. **声明 vs 执行分离** — Flags/Constraints/Safety/Contract 是声明;Invoke/Validate/PostMount 是执行
|
||||
2. **单一数据源** — 一份声明驱动 --help、Schema、catalog、runtime 校验
|
||||
3. **安全字段不互推** — Confirmation 单独驱动确认,Effect/Risk/Idempotency 原样发布
|
||||
4. **构建时拦截 > 运行时报错** — 声明不完整在命令注册时 panic,不等到用户触发
|
||||
5. **边界兼容** — Shortcut 暂由 adapter 转换,Leaf 直接声明 SafetySpec
|
||||
@@ -0,0 +1,236 @@
|
||||
# 命令框架对比:DWS command vs lark-cli vs GWS
|
||||
|
||||
本文档对比 DWS(钉钉工作区 CLI)、lark-cli(飞书 CLI)和 GWS(Google Workspace CLI / gcloud)三套命令框架的设计差异。
|
||||
|
||||
## 总览对比
|
||||
|
||||
| 维度 | DWS (command) | lark-cli | GWS (gcloud) |
|
||||
|------|---------------|----------|--------------|
|
||||
| 语言 | Go | Go | Python (gcloud) / Go (部分) |
|
||||
| CLI 框架 | cobra | cobra | argparse + calliope |
|
||||
| 调用底座 | MCP JSON-RPC | Lark REST SDK (`CallAPITyped`) | Google API Client |
|
||||
| 命令层次 | 2 层:LeafSpec + Shortcut | 3 层:Shortcuts + API Commands + Raw API | 2 层:surface commands + raw |
|
||||
| Schema 来源 | 代码声明投影 | 代码声明 + 运行时 introspection | API Discovery 文档自动生成 |
|
||||
| Agent 适配 | 内建 (dws.schema.*) | 内建 (--print-schema) | 外挂 (MCP adapter) |
|
||||
|
||||
## 架构对比
|
||||
|
||||
### DWS command
|
||||
|
||||
```
|
||||
corecmd.Spec (声明) → corecmd.New() → cobra.Command
|
||||
│
|
||||
├── contract.SafetySpec (运行时 + Schema 单一安全来源)
|
||||
├── FlagSpec[] (参数 + 回退链 + 绑定)
|
||||
├── Constraint[] (互斥/至少一个)
|
||||
├── ContractDecl (Agent Selection/DryRun/Interface)
|
||||
│
|
||||
└── Invoke / Orchestrate / RunE (执行)
|
||||
```
|
||||
|
||||
**核心特点**:
|
||||
- 声明与执行严格分离
|
||||
- SafetySpec 四个独立字段直接对齐 Agent Runtime Schema
|
||||
- 有效值回退链:flag → alias → env → default
|
||||
- 框架统一校验、装配、确认、投影
|
||||
- Schema 从代码声明直接投影,无外部 hint 文件
|
||||
|
||||
### lark-cli
|
||||
|
||||
```
|
||||
Shortcut (声明) → runner.Mount() → cobra.Command
|
||||
│
|
||||
├── Risk string (确认行为)
|
||||
├── Scopes / ConditionalScopes (OAuth 权限)
|
||||
├── Flag[] (参数 + Enum + Input sources)
|
||||
├── AuthTypes (user/bot)
|
||||
│
|
||||
├── DryRun hook → DryRunAPI
|
||||
├── Validate hook
|
||||
└── Execute hook → RuntimeContext → CallAPITyped
|
||||
```
|
||||
|
||||
**核心特点**:
|
||||
- Execute 内直接调 REST API (`CallAPITyped`)
|
||||
- DryRun 是独立 hook(返回结构化 API 计划)
|
||||
- 内建 OAuth scope 声明与预检
|
||||
- `--print-schema --flag-name` 运行时 introspection
|
||||
- 无 Schema 投影层,Agent 通过 introspection 动态发现
|
||||
|
||||
### GWS (gcloud 风格)
|
||||
|
||||
```
|
||||
API Discovery → 代码生成 → surface command
|
||||
│
|
||||
├── arguments (从 JSON Schema 自动生成)
|
||||
├── request/response 映射
|
||||
└── 自定义 action hook (少量)
|
||||
```
|
||||
|
||||
**核心特点**:
|
||||
- Schema-first:从 API Discovery 文档自动生成命令
|
||||
- 参数直接映射 API 字段(flat schema)
|
||||
- 人工 surface command 是 thin wrapper
|
||||
- Agent 适配通过 MCP 外部 adapter
|
||||
|
||||
## 核心设计差异
|
||||
|
||||
### 1. 声明粒度
|
||||
|
||||
| 能力 | DWS command | lark-cli | GWS |
|
||||
|------|-------------|----------|-----|
|
||||
| 参数别名 + 环境变量回退 | ✅ FlagSpec.Aliases + EnvVar | ❌ 无 | ❌ 无 |
|
||||
| 声明式约束 (互斥/至少一个) | ✅ Constraint[] | ❌ 只有 Validate hook | ✅ argparse group |
|
||||
| 安全契约 | ✅ SafetySpec(effect/risk/confirmation/idempotency) | Risk | 无 |
|
||||
| Schema 投影 (Agent metadata) | ✅ ContractDecl 内建 | ⚠️ 运行时 introspection | ❌ 外挂 |
|
||||
| 参数绑定 (flag name → API key) | ✅ FlagSpec.Bind | ❌ 手写 | ✅ 自动映射 |
|
||||
| ConstParams (固定载荷) | ✅ | ❌ 手写在 Execute | ✅ 隐式 |
|
||||
| 确认门顺序可配 (ConfirmFirst) | ✅ | ❌ 固定顺序 | ❌ 无确认机制 |
|
||||
|
||||
### 2. 执行模型
|
||||
|
||||
| 维度 | DWS command | lark-cli | GWS |
|
||||
|------|-------------|----------|-----|
|
||||
| 参数装配 | 框架自动 (BuildArgs) | 手写 (`runtime.Str()/Bool()`) | 自动映射 |
|
||||
| 派发方式 | Invoke(ctx, toolArgs) | Execute(ctx, runtime) | 自动调用 |
|
||||
| 多步编排 | Orchestrate(ctx) | Execute 内链式 CallAPITyped | 不支持 |
|
||||
| DryRun | 框架统一 (--dry-run flag) | 独立 DryRun hook 返回 API 计划 | 部分命令支持 |
|
||||
| 错误分类 | apperrors 类型化 | errs.Problem 类型化 | HTTP status 映射 |
|
||||
|
||||
### 3. Agent 适配
|
||||
|
||||
| 维度 | DWS command | lark-cli | GWS |
|
||||
|------|-------------|----------|-----|
|
||||
| 工具发现 | `dws schema --all` (静态 catalog) | `--print-schema` (运行时) | API Discovery |
|
||||
| 选择指引 | contract.SelectionSpec (UseWhen/AvoidWhen) | Description + Tips | 无 |
|
||||
| 安全声明 | contract.SafetySpec 直接声明 | Risk string | 无 |
|
||||
| dry-run 能力声明 | contract.DryRunSpec (reviewed) | DryRun hook 存在性 | 无 |
|
||||
| 接口模式 | contract.InterfaceSpec (local/mcp/composite) | 隐式 (全部 REST) | 隐式 (全部 REST) |
|
||||
|
||||
### 4. Schema 生命周期
|
||||
|
||||
```
|
||||
DWS: 代码声明 → code review → cobra annotation → catalog/metadata JSON
|
||||
(单一数据源,构建时验证完整性)
|
||||
|
||||
lark-cli: 代码声明 → 运行时 introspection → Agent 动态发现
|
||||
(无离线 catalog,Agent 必须执行命令才能发现)
|
||||
|
||||
GWS: API Discovery JSON → 代码生成 → surface command
|
||||
(Schema-first,但命令行体验受限于 API 形状)
|
||||
```
|
||||
|
||||
## 设计哲学对比
|
||||
|
||||
### DWS command 的选择
|
||||
|
||||
| 选择 | 理由 | 对比 |
|
||||
|------|------|------|
|
||||
| 框架装配参数 | 消除 N 个命令各写一份 toolArgs 装配 | lark-cli 每个 Execute 手动取 flag 值 |
|
||||
| SafetySpec 单一来源 | confirmation 驱动运行时,其余字段原样发布且互不推导 | lark-cli 只有 Risk 一个维度 |
|
||||
| 声明式约束 | 构建时校验合法性 + 投影到 Schema + 渲染帮助 | lark-cli 约束隐藏在 Validate 逻辑里 |
|
||||
| Schema 构建时投影 | 离线 catalog 支持 Agent 批量发现 | lark-cli 需要逐个命令 introspection |
|
||||
| 有效值回退链 | flag → alias → env 统一语义 | lark-cli 别名是独立 Flag 手动关联 |
|
||||
| ConfirmFirst | 精确建模遗留语义 | lark-cli 确认始终在 Execute 内 |
|
||||
|
||||
### lark-cli 的选择
|
||||
|
||||
| 选择 | 理由 | 对比 |
|
||||
|------|------|------|
|
||||
| 直连 REST API | 精确控制请求/响应,可处理分页/重试 | DWS 通过 MCP 间接调用 |
|
||||
| DryRun 返回 API 计划 | Agent 可预览将要发出的真实 HTTP 请求 | DWS dry-run 只展示参数 |
|
||||
| OAuth scope 声明 | 框架预检权限,失败提前 | DWS 依赖 MCP 层鉴权 |
|
||||
| `--print-schema` introspection | 运行时发现,无需维护离线 catalog | DWS 需要 re-generate |
|
||||
| Input sources (file/@path/stdin) | 丰富的输入方式声明 | DWS 无此抽象 |
|
||||
| PrintFlagSchema | 单 flag 级别的 JSON Schema 暴露 | DWS 只在 catalog 级别 |
|
||||
|
||||
### GWS (gcloud) 的选择
|
||||
|
||||
| 选择 | 理由 | 对比 |
|
||||
|------|------|------|
|
||||
| API Discovery 驱动 | 一份 Schema 生成所有:SDK/CLI/文档 | DWS/lark 手写 |
|
||||
| Flat parameter 映射 | API 字段 = CLI flag,零转换 | DWS 需要 Bind 映射 |
|
||||
| 无 shortcut 层 | API 粒度即用户粒度 | DWS/lark 有精选层 |
|
||||
|
||||
## 代码量对比
|
||||
|
||||
| 框架 | 核心框架代码 | 单命令声明开销 | 备注 |
|
||||
|------|-------------|---------------|------|
|
||||
| DWS command | ~1400 行 (command.go + contract_decl.go) | ~20-30 行 (纯声明) | 框架重、单命令轻 |
|
||||
| lark-cli | ~800 行 (runner.go + types.go + common.go) | ~50-150 行 (声明 + Execute 逻辑) | 框架轻、单命令重 |
|
||||
| GWS gcloud | ~5000+ 行 (calliope 框架) | ~10 行 (多数自动生成) | 框架最重、单命令最轻 |
|
||||
|
||||
## DWS 命令声明示例 vs lark-cli
|
||||
|
||||
### DWS (command / LeafSpec)
|
||||
|
||||
```go
|
||||
NewLeafCommand(LeafSpec{
|
||||
Use: "create",
|
||||
Short: "创建应用",
|
||||
Tool: "create_dev_app",
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
ConfirmFirst: true,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "name", Usage: "应用名称", Bind: "name",
|
||||
Trim: true, Required: true, RequiredHint: "--name 为必填"},
|
||||
},
|
||||
Contract: ContractDecl{
|
||||
Description: "创建开放平台企业内部应用",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "invocation"},
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "create then configure"},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建钉钉开放平台应用",
|
||||
UseWhen: []string{"需要新建企业内部应用"},
|
||||
AvoidWhen: []string{"应用已存在时用 update"},
|
||||
Examples: []string{`dws dev app create --name "Bot" --dry-run`},
|
||||
},
|
||||
},
|
||||
Call: devAppCall(runner),
|
||||
})
|
||||
```
|
||||
|
||||
### lark-cli (Shortcut)
|
||||
|
||||
```go
|
||||
var CalendarCreate = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+create",
|
||||
Description: "Create a new calendar event",
|
||||
Risk: "write",
|
||||
Scopes: []string{"calendar:calendar"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "summary", Desc: "Event title", Required: true},
|
||||
{Name: "start", Desc: "Start time (RFC3339)", Required: true},
|
||||
{Name: "end", Desc: "End time (RFC3339)", Required: true},
|
||||
{Name: "attendees", Type: "string_slice", Desc: "Attendee emails"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
return &common.DryRunAPI{
|
||||
Method: "POST",
|
||||
Path: "/open-apis/calendar/v4/calendars/{id}/events",
|
||||
Body: buildEventBody(rt),
|
||||
}
|
||||
},
|
||||
Execute: func(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
body := buildEventBody(rt)
|
||||
data, err := rt.CallAPITyped("POST",
|
||||
"/open-apis/calendar/v4/calendars/{id}/events", nil, body)
|
||||
if err != nil { return err }
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## 适用场景总结
|
||||
|
||||
| 场景 | 最适合 | 原因 |
|
||||
|------|--------|------|
|
||||
| MCP 后端 + Agent Schema 投影 | **DWS command** | 内建 Schema 声明、SafetySpec 契约、离线 catalog |
|
||||
| REST API 直连 + OAuth scope 管理 | **lark-cli** | CallAPITyped + scope 预检 + DryRun API 计划 |
|
||||
| API-first 大规模 surface 生成 | **GWS gcloud** | Discovery 驱动,一份 Schema 生成一切 |
|
||||
| 多步编排 (跨服务链式调用) | **lark-cli** / DWS Orchestrate | lark 的 CallAPITyped 链式 + DWS 的 Orchestrate |
|
||||
| 遗留系统迁移 (保持行为等价) | **DWS command** | ConfirmFirst + 回退链 + catalog 漂移门禁 |
|
||||
@@ -0,0 +1,269 @@
|
||||
# flag / help / schema 同源
|
||||
|
||||
- **状态**:已决策(路径 A + Contract 嵌入 Schema)
|
||||
- **相关**:[`rfc-command-framework-convergence.md`](rfc-command-framework-convergence.md)、[`schema-dynamic-endpoint-design.md`](schema-dynamic-endpoint-design.md)
|
||||
- **实现门面**:`LeafSpec` → `corecmd.Spec` → `corecmd.New`
|
||||
|
||||
## 1. 决策
|
||||
|
||||
采用 **路径 A:Contract / LeafSpec 为 CLI 表面权威**,并且 **Contract 必须嵌入进 Schema**。
|
||||
|
||||
「同源」的含义是:同一份 Contract(今日经 LeafSpec / `corecmd.Spec` 表达)同时决定——且门禁能证明——
|
||||
|
||||
1. cobra 实际注册的 flags / required / defaults;
|
||||
2. `--help` 的 Flags 与「参数约束」段;
|
||||
3. 运行时**组装后的** Schema 交付中的 parameters、关系约束和 SafetySpec:
|
||||
- **SchemaRegistry / Catalog ToolSpec wire** 供 `dws schema` / `--all` / 完整 leaf 载荷(lazy;首次 `dws schema` 触发完整装配)。
|
||||
- **`ResolveMeta` / `SafetyForCLIPath` / leaf `--help` Safety** 与装配同源:`deliverySchemaCatalog` sync.Once 装配后同步物化 `map[cli_path]CommandMeta`;稳态为 O(1) map lookup,不为 Meta 单独重做全量 ToolSpec/wire 投影。
|
||||
Agent metadata 在组装期间经内存 inject,不落盘、不 embed;`schema_agent_metadata/` 已退役,若存在则 policy 失败。
|
||||
|
||||
嵌入机制(已落地):
|
||||
|
||||
```text
|
||||
corecmd.Spec
|
||||
→ RegisterFlags + embedContractIntoSchema
|
||||
→ cobra annotations:
|
||||
dws.schema.contract=command
|
||||
dws.schema.property / type / required (per flag)
|
||||
dws.schema.constraints
|
||||
→ RegisterRuntimeContractFinal(SafetySpec + ContractDecl)
|
||||
→ Schema 组装透传 Contract Final
|
||||
(组装时内存 inject Agent metadata)
|
||||
→ RegisterSchemaSourceRoot → ResolveSchemaBuild
|
||||
→ SchemaRegistry (+ Meta cache map) / dws schema wire projection
|
||||
```
|
||||
|
||||
command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷,不使用字符串枚举注解。
|
||||
|
||||
### 1.1 硬规则:声明 = 最终数据源(Schema 透传)
|
||||
|
||||
受管命令进入 Schema 的叶子数据由 **Contract 声明**定义最终值;框架(`corecmd.New`)做**类型转换**并注册,Schema 组装**透传**,不得:
|
||||
|
||||
- 把声明序列化成 JSON 注解再解析;
|
||||
- 在声明体系里再挂「评审字段」并行权威;
|
||||
- 用 hints/registry 盖写已声明字段。
|
||||
|
||||
**declare-vs-delivery 例外(Title / Description)**:构造期 `ContractDecl.Description` **必填**(声明证据),但这不是「declare = wire 最终值」。Catalog **交付**时:
|
||||
|
||||
- **description**:有 Cobra Long → 交付 Long(provenance `cobra_help` / `cobra_help_preferred`);无 Long → 交付声明(`contract_final`)。**Short 不进入 description**。
|
||||
- **title**:声明 `ContractDecl.Title` 优先,否则 Cobra Short,再 MCP;组装 stamp 真实 winner。
|
||||
|
||||
这是一条权威链上的显式交付偏好,不是双权威(见 RFC §5.0.4)。
|
||||
|
||||
迁移期未迁完的叶子可暂走旧组装路径;**新声明面不含 review_reason / reviewed 字段**。写命令未设 `Safety` 时,过渡期仍可用 `runtime_gate` annotate(`HOM-S2`)。
|
||||
|
||||
**不采用**路径 B(以 MCP meta / 已退役的 `schema_mcp_metadata` 生成全部 CLI flag/help/schema)作为主权威。钉钉 MCP meta 不是飞书 OAPI:粒度与 CLI 特有语义(二选一、OmitEmpty、ConstParams、write guard)无法从裸 meta 推出;强行生成会违反「Schema 描述 CLI,不制造 CLI」。
|
||||
|
||||
路径 B 仅允许作为 **可选的 1:1 MCP 透传叶子通道**(见 §5),不得覆盖 LeafSpec / Shortcut 主路径。
|
||||
|
||||
对外叙事:与飞书 **分层单权威** 同构——API/透传叶可用平台事实,产品 CLI / Shortcut 用手写契约 + 执行体——而不是「全家只有平台 meta」。
|
||||
|
||||
### 1.2 声明(declare):写什么、写在哪、投影到哪
|
||||
|
||||
**定义**:声明 = 在 Contract 结构体的**数据字段**上写出事实;`corecmd.New` 据此注册 cobra、渲染 help、写入 `dws.schema.*`。钩子闭包(`Validate` / `Call` / `PostMount` / `RunE`)里的逻辑**不算**声明——即使行为正确,也不能单靠钩子让 Schema/help「猜出」该事实。
|
||||
|
||||
命令框架边界与今日/目标对应见 RFC [`rfc-command-framework-convergence.md`](rfc-command-framework-convergence.md) **§5.0**(框架上的「声明」定义);本节给字段级表与示例。
|
||||
|
||||
**唯一写入面**(三选一,语义相同):
|
||||
|
||||
| 入口 | 类型 | 归一 |
|
||||
|---|---|---|
|
||||
| Leaf 命令 | `helpers.LeafSpec` | `FromLeafSpec` → `corecmd.Spec` |
|
||||
| Shortcut | Shortcut 声明(经 `FromShortcut`) | → `corecmd.Spec` |
|
||||
| 直接基座 | `corecmd.Spec` | `corecmd.New` |
|
||||
|
||||
今日产品 CLI 叶子以 **`LeafSpec` 为声明门面**;字段与 `corecmd.Spec` 契约面一一对应。
|
||||
|
||||
#### 1.2.1 契约字段(算声明)
|
||||
|
||||
| 字段 | 声明什么 | 运行时 | 嵌入 Schema / help |
|
||||
|---|---|---|---|
|
||||
| `Flags[]`(`FlagSpec` / `LeafFlag`) | 用户可见参数面:名、类型、默认、必填、usage | 注册 cobra flag;装配 toolArgs | `dws.schema.property` / `type` / `required`;`--help` Flags |
|
||||
| `Constraints[]` | 跨 flag 关系:`at_least_one` / `exactly_one` / `mutually_exclusive`;`custom` 记录钩子校验 | 通用关系由 `ValidateConstraints` 执行;`custom` 由 `Validate` 执行 | `dws.schema.constraints`;`--help`「参数约束」 |
|
||||
| `Safety`(`contract.SafetySpec`) | effect/risk/confirmation/idempotency 四个独立事实 | `confirmation=user_required` 时 `ConfirmSafety`;`--yes` / `--dry-run` 跳过 | 同一个 SafetySpec 原样进入 Contract Final(`HOM-S1`) |
|
||||
| `ConstParams` | 固定载荷(不上 flag 表) | 并入 toolArgs;不满足 Required | **不**投影为用户 parameter |
|
||||
| `Use` / `Short` / `Long` / `Example` | 命令身份文案与示例 | cobra 自身 | help;identity 以 collector 收集的 `ContractFinal.Identity` 声明为准(reviewed registry 已退役) |
|
||||
|
||||
`FlagSpec` 子字段(声明细节):
|
||||
|
||||
| 子字段 | 作用 | 是否进 Schema parameters |
|
||||
|---|---|---|
|
||||
| `Name`, `Usage`, `Kind`, `Default` | 注册名/说明/类型/DefValue | 是(name/type;default 与 cobra 对齐) |
|
||||
| `Required` / `MarkRequired` | 非空校验 / cobra 硬必填 | 是(`required`) |
|
||||
| `RequiredHint`, `Aliases`, `EnvVar` | 校验提示、隐藏别名、环境回退 | 否(执行细节;别名不上主 parameter 表) |
|
||||
| `ArgDefault`, `Bind`, `OmitEmpty`, `Trim`, `Transform` | toolArgs 装配语义 | 否(载荷细节;`Bind` 可进 property 映射,但不另造 flag) |
|
||||
|
||||
#### 1.2.2 编排 / 执行字段(不算声明)
|
||||
|
||||
| 字段 | 角色 | 禁止用来「冒充」的声明 |
|
||||
|---|---|---|
|
||||
| `Validate` | 条件式/领域校验钩子 | 不得在此 `Flags().String(...)` 注册业务 flag;不得只靠钩子表达「必填/互斥」而不写 `Flags`/`Constraints` |
|
||||
| `Call` / `Invoke` / `Orchestrate` | 执行体 | 不得 `params[k]=…` 装配业务参数(应在 `Flags`/`ConstParams`) |
|
||||
| `PostMount` | 挂载后收尾(annotate、领域工具注入) | 不得注册业务 flag;分页等横切由领域工具注入并可走 annotate |
|
||||
| `RunE` | 逃生舱(整段手写) | 表面事实仍须 Flags 声明;框架仍按 Safety 执行确认 |
|
||||
| `Server` / `Tool` | MCP 路由 | 不构成 CLI parameter 声明 |
|
||||
|
||||
#### 1.2.3 最小声明示例
|
||||
|
||||
```go
|
||||
// 读:Safety 四字段显式对齐 Schema
|
||||
NewLeafCommand(LeafSpec{
|
||||
Use: "get", Short: "…", Tool: "…",
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Flags: []LeafFlag{
|
||||
{Name: "unified-app-id", Usage: "…", Bind: "unifiedAppId", Trim: true, Required: true},
|
||||
},
|
||||
Call: devAppCall(runner), PostMount: devAppMeta(tool),
|
||||
})
|
||||
|
||||
// 写:同一个 SafetySpec 同时驱动确认与 Schema
|
||||
NewLeafCommand(LeafSpec{
|
||||
Use: "publish", Short: "…", Tool: "…",
|
||||
Flags: []LeafFlag{ /* … */ },
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Call: devAppCall(runner), PostMount: devAppMeta(tool),
|
||||
})
|
||||
|
||||
// 迁移期旧写命令:确认走 annotate,见 §1.3 —— 不是新声明面
|
||||
NewLeafCommand(LeafSpec{
|
||||
Use: "create", /* Flags… */,
|
||||
Validate: func(cmd *cobra.Command, _ []string) error {
|
||||
return devAppRequireWriteGuard(cmd, "create") // 执行守卫,不是 Contract 声明
|
||||
},
|
||||
Call: devAppCall(runner),
|
||||
PostMount: devAppMetaWrite(tool), // 人工标注 runtime_gate
|
||||
})
|
||||
```
|
||||
|
||||
**空 `Safety` 的含义**:command 为兼容旧只读叶保留 `read/low/not_required/idempotent` 默认。因此「会改状态却留空 Safety」**不是**合法声明;新 Leaf 必须写完整 SafetySpec,未迁移旧路径则按 §1.3 标注 gate。
|
||||
|
||||
### 1.3 人工标注(annotate):声明的补充通道
|
||||
|
||||
当事实无法或不愿放进 Contract 字段时,必须**显式**落注解 / 评审源,禁止组装期推断:
|
||||
|
||||
| 标注手段 | 典型值 | 何时用 |
|
||||
|---|---|---|
|
||||
| `cli.AnnotateRuntimeGate` / `devAppMetaWrite` | `dws.schema.runtime_gate=devAppRequireWriteGuard` | 尚未迁移到 SafetySpec 的旧写命令(`HOM-S2`) |
|
||||
| `cli.AnnotateRuntimeRisk` | `dws.schema.risk=…` | Shortcut 暂存的旧兼容路径;command/Leaf 禁止新增 |
|
||||
| `cli.AnnotateRuntimeFlag` / Constraints | 与 embed 同形 | 手写 cobra 叶补齐表面(长期应迁入 Contract) |
|
||||
| reviewed `schema_hints/metadata` Safety(已退役) | effect/risk/confirmation | 已删:`schema_hints/` 不得重现;受管命令以 Contract.Safety / gate 为准 |
|
||||
|
||||
标注与声明冲突时:**Contract 声明胜**(路径 A)。标注不得发明未注册的 CLI flag。
|
||||
|
||||
### 1.4 Schema 全覆盖(`ToolSpec` 无空洞)
|
||||
|
||||
`dws schema` 叶子模型是 `cli.ToolSpec`。命令框架必须为**每一个字段组**指定权威;完整矩阵在 RFC **§5.0.4**,摘要:
|
||||
|
||||
| ToolSpec 组 | 权威类 | 框架声明字段 / 其它源 |
|
||||
|---|---|---|
|
||||
| Identity | 声明源(identity collector 收集 `ContractFinal.Identity`;reviewed `schema_command_registry` 已退役) | `ContractDecl.Identity` 声明 |
|
||||
| Display / Title / Description | 声明证据 + 交付偏好(非双权威) | **title**:ContractDecl 优先,否则 Cobra Short,再 MCP;**description**:构造期 Description 必填;Catalog 交付 Long→`cobra_help`,无 Long→`contract_final`;**Short 不进 description**(RFC §5.0.4) |
|
||||
| Parameters.`name/type/required/default/property` | **声明**(或同形 annotate) | `Flags` / `Bind` |
|
||||
| Parameters.`description` | 声明 usage(`FlagSpec.Usage` / ParamDecl) | `schema_hints/` 已退役;不得用 overlay 改 type/required/default |
|
||||
| Parameters.`interface_*` | 评审源 | MCP meta / bindings;**不造 flag** |
|
||||
| Constraints | **声明** | `Constraints` |
|
||||
| Positionals | **声明** 或显式 annotate | 目标 `Args`;禁止推断 |
|
||||
| Safety.`effect/risk/confirmation/idempotency` | **声明**完整 `Safety`,或迁移期 `runtime_gate` / reviewed Safety | 四字段独立;不得互相推导 |
|
||||
| DryRun | 评审源 | dry-run capabilities registry |
|
||||
| Interface | 评审源 | MCP + 内存 inject 的 Agent metadata |
|
||||
| Selection | 声明(ContractFinal / ProductDecl) | `ContractDecl.Selection` / `ProductDecl` |
|
||||
| FieldProvenance / Extensions | 组装派生或评审扩展 | 组装器;与 delivered value 一致 |
|
||||
| (非 Schema parameter)ConstParams | **声明** | 载荷;不上 parameters 表 |
|
||||
|
||||
验收:新增 Schema 字段必须同步改 RFC §5.0.4 + 本表;受管写命令 Safety 不得无主。
|
||||
|
||||
## 2. 字段归属(每一类恰好一个写入者)
|
||||
|
||||
| 字段类 | 权威 | 投影到 |
|
||||
|---|---|---|
|
||||
| flags / defaults / required / enum / 关系约束 / 运行时 Risk | Contract(LeafSpec / `corecmd.Spec` 门面) | cobra、`--help`、Schema `parameters` / constraints / confirmation |
|
||||
| ConstParams、Bind、OmitEmpty、Transform | 同上(载荷声明,不上 flag 表) | toolArgs;Schema 不把 ConstParams 伪装成用户 flag |
|
||||
| canonical path / aliases / navigation / exposure | identity collector 收集的 `ContractFinal.Identity` 声明(reviewed `schema_command_registry` 已退役) | Schema identity |
|
||||
| use_when / avoid_when / examples / agent_summary 文案 | `ContractDecl.Selection` / `ProductDecl` | Schema selection |
|
||||
| RPC tool 形状、`interface_ref`、interface 描述 | leaf `Contract.Interface` + `ParamDecl`(`schema_parameter_mapping_ledger.go` 仅 mapping_exclusions / removals;`schema_mcp_metadata` 已退役) | Schema `interface_*` 字段;**不得创建 flag** |
|
||||
| 参数描述 overlay(可选) | 生产 metadata 壳为空;参数事实走 ParamDecl / FlagSpec | **Contract/cobra 胜** |
|
||||
| 遗留 Safety 文案(迁移期) | 生产 metadata 壳为空;Safety 走 Contract | 以 Contract.Safety / runtime_gate 为准(见 §4) |
|
||||
| dry-run 正能力 | reviewed dry-run registry | Schema `dry_run` |
|
||||
| positionals | Contract Args / 显式 annotate | Schema `positionals` |
|
||||
| FieldProvenance | 组装派生 | Schema provenance(与值一致) |
|
||||
|
||||
Selection **刻意不**由单命令 Contract 取代(RFC 决策 8 / schema 设计硬规则);identity 的历史决策是不进 Contract、归 reviewed `CommandRegistry`,该 registry 已退役,现由 identity collector 收集 `ContractFinal.Identity` 声明(声明即 identity)。**完整无空洞表见 RFC §5.0.4。**
|
||||
|
||||
## 3. 当前缺口与目标闭环
|
||||
|
||||
已具备:
|
||||
|
||||
- Flags / ConstParams / Constraints → 注册、校验与 `ConstraintHelp`;SafetySpec → 运行时 `ConfirmSafety`(command);
|
||||
- Call / Execute 作为执行体;业务参数不得在 Call 内装配(helpers 门禁);
|
||||
- **Contract → Schema 嵌入**:参数/约束写原生 annotation,SafetySpec 与 ContractDecl 注册为类型化 Contract Final 并由 Schema 组装透传;
|
||||
- Selection 权威为 `ContractDecl.Selection` / `ProductDecl`(`contract_final`);`schema_hints/` 已退役。
|
||||
|
||||
已进 CI(`make policy` → `check-schema-catalog.sh` / `check-runtime-confirmation-truth.sh`):
|
||||
|
||||
| Gate ID | CI 入口(`-run` 白名单 / 脚本) |
|
||||
|---|---|
|
||||
| `HOM-P1` / `HOM-D1` | `./internal/app`:`TestFinalSchemaParametersMatchExecutableHelpFlags`、`TestDeliverySchemaParametersMatchExecutableHelpFlags` |
|
||||
| `HOM-P2`(参数映射/bindings 子集) | `./internal/cli`:`TestSchemaParameterBindingsMatchReviewedBaselineAndDeliveryCatalog`、`TestDeliveryCatalogMCPParameterMappingsAreComplete` 等 bindings 门禁 |
|
||||
| `HOM-S1` / `HOM-S2`(confirmation 同源) | `./internal/cli/homology`:`TestUserRequiredSafetyHomologyWithRuntimeGate` + `check-runtime-confirmation-truth.sh`;`./internal/app`:`TestSheetFinalSchemaConfirmationMatchesRuntimeGuards` |
|
||||
| 词汇/决策钉扎 | `./internal/cli/homology`:`TestHomologyDecisionDocPinsPathAAndGateIDs`、`TestMCPPassthroughAdmissionExcludesLeafAndShortcut`、`TestHomologyCIEntrypointsPinned` |
|
||||
|
||||
仍缺(未宣称全量 CI 覆盖):
|
||||
|
||||
1. 独立可执行的 `HOM-P3`(constraints ≡ AnnotateConstraints)与 `HOM-S3`(read 不得误投影 user_required)全量 gate;
|
||||
2. `HOM-I1` 作为单独 gate ID 的显式用例(MCP bindings ⊆ Contract flags 已有映射审计子集,但未钉 `HOM-I1` 标签)。
|
||||
|
||||
已落地(写命令确认语义,`HOM-S2`):
|
||||
|
||||
- `AnnotateRuntimeGate` / `dws.schema.runtime_gate`;Leaf `PostMount: devAppMetaWrite`;手写 delete/robot 等同路径显式标注;
|
||||
- Schema 组装在无 Contract Safety 但有 gate 时 overlay `confirmation=user_required`(`applyContractGateToSafety`);
|
||||
- AST/mount 测试:新 Leaf 须声明完整 SafetySpec;尚未迁移的旧路径须有 runtime_gate。
|
||||
|
||||
## 4. Schema 投影与 Safety 门禁规划
|
||||
|
||||
以下门禁 ID 稳定,便于 CI 认领。§3 表标明哪些已挂入 `check-schema-catalog.sh`。
|
||||
|
||||
| Gate ID | 断言 | 范围 | CI |
|
||||
|---|---|---|---|
|
||||
| `HOM-P1` | 受管 leaf 的 schema `parameters[].name` 集合 ≡ cobra 本地 flag 名集合(排除全局 persistent) | LeafSpec / Contract 编译命令 | **已进**(app help↔schema) |
|
||||
| `HOM-P2` | schema parameter `type` / `required` / `default` 与 cobra DefValue / MarkFlagRequired / FlagSpec 一致;不得用已退役 hints overlay 改写这三项 | 同上 | **部分**(bindings/mapping 门禁) |
|
||||
| `HOM-P3` | schema 关系约束(require_one_of / mutually_exclusive)≡ Contract/Leaf `Constraints` 投影(与 `AnnotateConstraints` 同构) | 声明了 Constraints 的命令 | 规划 |
|
||||
| `HOM-S1` | Contract/Leaf `user_required` Safety 与运行时 Confirm/gate 同源,且 help Safety 行同语义 | 受管写/破坏性命令 | **已进** |
|
||||
| `HOM-S2` | 若命令走显式 write guard(如 `devAppRequireWriteGuard`)而非完整 SafetySpec,则必须人工标注 `dws.schema.runtime_gate`;Schema 不得呈 `confirmation=not_required`;符合 §1.1 declare OR annotate | 今日 devapp 写命令 | **已进**(同源测试含 gate 路径) |
|
||||
| `HOM-S3` | `Risk=read`(或空→read)不得投影为 `user_required`,除非有 reviewed exclusion reason | 受管读命令 | 规划 |
|
||||
| `HOM-I1` | `interface_ref` 存在时,bindings 覆盖的 CLI flag ⊆ Contract flags;MCP meta **不**引入额外 CLI flag | 有 MCP 绑定的命令 | **部分**(mapping 审计) |
|
||||
| `HOM-D1` | `dws <path> --help` Flags 段与 schema leaf parameters 零未解释增量 | 受管公开 leaf | **已进**(与 HOM-P1 同测) |
|
||||
|
||||
落地顺序建议:
|
||||
|
||||
1. 保持 `HOM-P1`/`HOM-D1`/`HOM-S1`/`HOM-S2` 在 `check-schema-catalog.sh` 白名单中(勿再只靠词汇钉扎);
|
||||
2. 补独立 `HOM-P3` / `HOM-S3` 可执行 gate,并把 `HOM-P2`/`HOM-I1` 从「部分」升到全量标签;
|
||||
3. 新 Leaf 继续走 Contract 嵌入;禁止 `schema_hints/` 回潮。
|
||||
|
||||
## 5. 路径 B 子通道:1:1 MCP 透传叶(可选,非主路径)
|
||||
|
||||
仅当同时满足以下条件时,才允许「从 MCP meta 生成 flag/help/schema 参数」:
|
||||
|
||||
1. CLI path ↔ 单一 MCP tool **严格 1:1**,无多步、无按名解析、无本地 effect;
|
||||
2. 无不在 MCP input schema 中的 CLI 特有 flag(含 guard 专用语义 flag 除外的全局 `--yes`/`--dry-run`);
|
||||
3. 无 ConstParams / Transform / 跨 flag 约束 / Call 内业务逻辑;
|
||||
4. Risk/confirmation 在 meta 或并列 reviewed Safety 中有显式来源,不靠生成器猜测;
|
||||
5. 在 identity 声明面标记 `surface_kind=mcp_passthrough`(名称可调整;原计划标在 reviewed registry,该 registry 已退役),且 **不得**与 LeafSpec/Shortcut 手写定义双注册同一 `cli_path`;
|
||||
6. 文档与门禁写明:该通道是子集优化,失败时回退/禁止扩张到产品 CLI。
|
||||
|
||||
显式排除(永远走路径 A / Shortcut):
|
||||
|
||||
- 全部 `LeafSpec` 命令(含 `dws dev app …`);
|
||||
- 全部 `+shortcut` 与 smart 编排;
|
||||
- 任何需要 `devAppRequireWriteGuard`、cursor 工具注入、或响应投影的命令。
|
||||
|
||||
## 6. 非目标
|
||||
|
||||
- ~~不把 canonical identity / 导航塞进 Contract(仍归 reviewed `CommandRegistry`)~~ —— 该非目标已被后续演进取代:reviewed `CommandRegistry` 已退役,identity 现由 collector 收集 `ContractFinal.Identity` 声明(声明即 identity,不再是独立评审文件)。selection 文案已由 `ContractDecl.Selection` / `ProductDecl` 声明(非 hints)。
|
||||
- 不要求删除 LeafSpec 门面。
|
||||
- 不把「生成 catalog 字节一致」当作运行时同源的充分条件(仍需 `HOM-*` 与差分门禁)。
|
||||
+2
-2
@@ -111,9 +111,9 @@ dws contact user search --query "Alice" -o result.json
|
||||
|
||||
## Schema Introspection / Schema 查询
|
||||
|
||||
`--help` 展示当前二进制的 Cobra 命令和可接受 flag,`dws schema` 查询同版本内嵌的 Agent 命令契约。Schema 查询不访问 MCP endpoint、不执行 `tools/list`,也不搜索钉钉文档或任何业务数据。
|
||||
`--help` 展示当前二进制的 Cobra 命令和可接受 flag,`dws schema` 查询同版本运行时组装的 Agent 命令契约。Schema 查询不访问 MCP endpoint、不执行 `tools/list`,也不搜索钉钉文档或任何业务数据。
|
||||
|
||||
Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 来自 reviewed `CommandRegistry`,并在发布时逐项绑定当前 Cobra tree。编辑 `internal/cli/schema_command_registry.json` 时必须遵守同目录的 `schema_command_registry.schema.json`;普通生成流程只校验该 reviewed input,不会覆盖它。Native annotation 只做实现一致性校验;Catalog 是该统一强类型契约的发布输出,不作为命令发现或下一轮生成的输入。
|
||||
Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 收集自命令树叶节点上的 `ContractFinal.Identity`(`CollectIdentitySpecs`),并在发布时逐项绑定当前 Cobra tree。原 reviewed `schema_command_registry/` 已退役,身份变化通过编辑叶节点声明完成。Native annotation 只做实现一致性校验;Catalog 是该统一强类型契约的发布输出,不作为命令发现或下一轮生成的输入。
|
||||
|
||||
### 路径写法
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,17 +6,21 @@ DWS Schema 是当前二进制公开 CLI 的版本化 Agent 执行契约。它描
|
||||
|
||||
设计遵循三条硬规则:
|
||||
|
||||
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、manual hint、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。
|
||||
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、ProductDecl / leaf `Contract`、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。interface 事实由 leaf `Contract` / `ParamDecl` 声明(`schema_mcp_metadata` 已退役),**不得**从 MCP meta 生成 CLI flag(见 §4.1 同源决策)。
|
||||
2. **所有来源只解析一次。** 来源经过统一 resolver 进入 typed `SchemaRegistry`,所有查询、导出和门禁都消费同一个 `SchemaRegistry/SchemaIndex`。
|
||||
3. **Registry-first,Catalog 只出不进。** reviewed `CommandRegistry` 是稳定 command identity/navigation 的唯一事实源;`schema_catalog/`(`catalog.json` + 每产品 `tools/<product>.json`)和其他生成 JSON 只是下游发布物,不能成为命令、metadata 或下一轮 Catalog 的来源。运行时 production loader 解码 embedded snapshot 只是交付边界,不是 source resolution。
|
||||
3. **Collector-first,Catalog 只出不进。** identity collector(`CollectIdentitySpecs`,遍历携带 `ContractFinal.Identity` 的 live Cobra leaves)是稳定 command identity/navigation 的唯一事实源;reviewed `schema_command_registry/` 已退役,identity 由声明(Contract)即代码提供,不再有独立的 reviewed identity 文件。production 通过 `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` 组装 `SchemaRegistry`,并从它投影 ToolSpec wire 与 `ResolveMeta`。`cmd_schema_catalog` 只能生成 CI/local dump,`internal/cli/schema_catalog/`、`schema_meta_index.gob` 和 `schema_meta_index.json` 不得提交或成为运行时来源。`schema_agent_metadata/` / `schema_hints/` / `schema_command_registry/` 已退役;若存在则 policy 失败。生产 Agent selection / safety / interface 权威为 leaf `ContractFinal` 与 `ProductDecl`;`agent_metadata_inject.go` / `InstallBuildTimeAgentMetadataJSON` 仅作 `cmd_schema_catalog` CI/local dump 辅助,不得作为生产权威。
|
||||
|
||||
Schema 不调用 MCP `tools/list`,不访问网络,也不读取用户本地 discovery cache。
|
||||
|
||||
**flag / help / schema 参数面同源**(已决策):Contract / LeafSpec 为 CLI 表面权威;分层字段归属与门禁 ID 见 [`flag-help-schema-homology.md`](flag-help-schema-homology.md)。
|
||||
|
||||
## 2. 单向数据流
|
||||
|
||||
```text
|
||||
schema_command_registry.json (reviewed CommandRegistry source)
|
||||
+ reviewed manual command additions
|
||||
identity collector (CollectIdentitySpecs)
|
||||
walks live Cobra leaves carrying ContractFinal.Identity;
|
||||
reviewed exclusions applied; single identity source
|
||||
(reviewed schema_command_registry/ retired)
|
||||
|
|
||||
v
|
||||
EffectiveCommandRegistry
|
||||
@@ -28,26 +32,13 @@ schema_command_registry.json (reviewed CommandRegistry source)
|
||||
v
|
||||
BoundCommandRegistry
|
||||
|
|
||||
+----------------------+
|
||||
|
|
||||
skills/mono Markdown + internal/cli/schema_hints/*.json |
|
||||
+ schema_mcp_metadata.json |
|
||||
| |
|
||||
v |
|
||||
Agent-metadata normalization |
|
||||
| |
|
||||
v |
|
||||
schema_agent_metadata/*.json |
|
||||
(generated normalized input) |
|
||||
| |
|
||||
+-----------------------+
|
||||
|
|
||||
v
|
||||
live Cobra flag facts / typed parameter metadata
|
||||
+ schema_hints/metadata/*.json (reviewed parameter overlay + safety)
|
||||
+ schema_hints/selection/*.json (reviewed Agent selection prose)
|
||||
+ schema_parameter_bindings.json (reviewed flag -> RPC property)
|
||||
+ schema_mcp_metadata.json (pinned, sanitized interface facts)
|
||||
+ normalized Agent metadata
|
||||
+ leaf Contract (Safety / ContractDecl / ParamDecl → contract_final)
|
||||
+ ProductDecl (product routing prose; production Agent authority)
|
||||
+ schema_parameter_mapping_ledger.go (reviewed mapping exclusions / removals)
|
||||
+ leaf Contract.Interface / ParamDecl (declared interface facts)
|
||||
+ skills/mono Markdown (evidence only; not concatenated)
|
||||
|
|
||||
v
|
||||
source adapters + resolvers
|
||||
@@ -60,18 +51,14 @@ live Cobra flag facts / typed parameter metadata
|
||||
+-----------+-----------+
|
||||
| |
|
||||
v v
|
||||
build-time typed gates snapshot serializer
|
||||
|
|
||||
v
|
||||
schema_catalog/
|
||||
(catalog.json + tools/<product>.json,
|
||||
release output only)
|
||||
|
|
||||
v
|
||||
go:embed -> typed loader
|
||||
build-time typed gates RegisterSchemaSourceRoot
|
||||
-> ResolveSchemaBuild
|
||||
(runtime assembly; lazy Once)
|
||||
|
|
||||
v
|
||||
SchemaRegistry + SchemaIndex
|
||||
+ ResolveMeta projection cache
|
||||
(in-process map; not gob/json fixture)
|
||||
|
|
||||
+---------------------+------------------+
|
||||
| | |
|
||||
@@ -82,9 +69,12 @@ live Cobra flag facts / typed parameter metadata
|
||||
|
|
||||
v
|
||||
runtime query + delivery gates
|
||||
(cmd_schema_catalog = CI/local dump only;
|
||||
InstallBuildTimeAgentMetadataJSON = dump helper,
|
||||
not production Agent authority)
|
||||
```
|
||||
|
||||
`--help` 是 Cobra 自身的人类可读投影,不从 Catalog 生成。Schema projections 和 `--help` 共享同一真实 Cobra 命令面,但承担不同职责。Binder 之后不得再从 annotation、manual hint 或生成 JSON 重新解析 command identity。
|
||||
`--help` 是 Cobra 自身的人类可读投影,不从 Catalog 生成。Schema projections 和 `--help` 共享同一真实 Cobra 命令面,但承担不同职责。Binder 之后不得再从 annotation、已退役 hint overlay 或生成 JSON 重新解析 command identity。
|
||||
|
||||
## 3. 与 Lark 的关系
|
||||
|
||||
@@ -95,8 +85,10 @@ DWS 与 Lark 保持**架构同构**,而不是强行复制字段:
|
||||
| typed command/metadata registry | `EffectiveCommandRegistry`、`BoundCommandRegistry` 与最终 `SchemaRegistry` |
|
||||
| navigation catalog/index | 从同一 `ToolSpec` 派生的 `SchemaIndex` |
|
||||
| schema renderer/envelope | overview、product/group、leaf、`--all` projections |
|
||||
| API Commands ← 平台 OAPI meta | **不**作为 DWS 主路径;可选 1:1 MCP 透传子集见同源文档 §5 |
|
||||
| Shortcuts ← 手写声明 + Execute | LeafSpec / Shortcut + Contract 表面(路径 A) |
|
||||
|
||||
共同点是:强类型 registry 持有已审核、已绑定、已解析的事实,index 只负责确定性导航,renderer 只投影,不重新读取来源或做 precedence。DWS 的 base Registry 与 reviewed manual command additions 在绑定前合并为唯一的 `EffectiveCommandRegistry`,因此不存在 “native-first”、“legacy registry fallback” 或 Catalog fallback。
|
||||
共同点是:强类型 registry 持有已审核、已绑定、已解析的事实,index 只负责确定性导航,renderer 只投影,不重新读取来源或做 precedence。DWS 的 identity collector 从携带 `ContractFinal.Identity` 的 live Cobra leaves 收集 identity,绑定前生成唯一的 `EffectiveCommandRegistry`(reviewed `schema_command_registry/` 已退役),因此不存在 “native-first”、“legacy registry fallback” 或 Catalog fallback。飞书也是**分层单权威**(API 用平台 meta,Shortcut 用手写契约),不是全家只有 meta——DWS 对齐的是这一分层,而不是「用 MCP meta 生成全部 CLI」。
|
||||
|
||||
DWS 内部 resolved model 为:
|
||||
|
||||
@@ -121,26 +113,38 @@ DWS 当前对外仍保留兼容 wire:leaf 使用 flat `parameters`,安全和
|
||||
|
||||
| 来源 | 负责内容 | 明确不负责 |
|
||||
|---|---|---|
|
||||
| `schema_command_registry.json` | reviewed `CommandRegistry`:稳定 canonical identity、primary CLI path、alias、exposure 和导航 | 创建 Cobra 命令/flag、参数、安全、endpoint/token |
|
||||
| reviewed manual command additions | 将一个精确存在的 runnable Cobra leaf 合并进 `EffectiveCommandRegistry`;必须 reviewed 且带 reason | 运行时 fallback、覆盖冲突 identity、创建命令 |
|
||||
| Go/Cobra | 路径是否真实可执行、Cobra 接受的 flag、CLI 类型/默认值、执行校验、help 文本 | 稳定 canonical identity、Agent 场景选择、虚构 RPC |
|
||||
| Contract / LeafSpec / `corecmd.Spec` | **CLI 表面权威**:flags、defaults、required、enum、关系约束、运行时 Risk;编译为 cobra 与 help | canonical identity、selection 文案、虚构 RPC |
|
||||
| identity collector(`CollectIdentitySpecs`) | 从 live Cobra leaves 的 `ContractFinal.Identity` 声明收集稳定 canonical identity、primary CLI path、alias、exposure 和导航(reviewed `schema_command_registry/` 已退役) | 创建 Cobra 命令/flag、参数、安全、endpoint/token |
|
||||
| reviewed exclusions(`ReviewedRuntimeSchemaExclusions`) | exact、reviewed、带 reason 地将指定 public runnable leaf 排除出 effective 表面 | 运行时 fallback、prefix/wildcard 排除、创建命令 |
|
||||
| Go/Cobra | Contract 编译后的可执行投影:路径是否真实可执行、Cobra 接受的 flag、DefValue、help 文本 | 稳定 canonical identity、Agent 场景选择、虚构 RPC;**不得**成为与 Contract 平行的第二套 flag 权威 |
|
||||
| native Schema identity annotations | implementation-side consistency evidence;存在时必须与 `EffectiveCommandRegistry` 精确一致 | 提供、补全、推断或覆盖 identity |
|
||||
| `schema_hints/metadata/*.json` parameter overlays | 精确覆盖现有 flag 的描述、映射、类型和 required 语义;并承载 safety / `runtime_gate` / interface | 创建命令/flag、绕过 completeness、虚构 RPC |
|
||||
| typed parameter metadata / constraints | `required_when`、one-of、互斥、联动、格式、枚举、位置参数 | 命令 identity |
|
||||
| `schema_parameter_bindings.json` | 稳定 CLI flag 到 RPC property 的映射 | 命令发现、risk 推断 |
|
||||
| `schema_mcp_metadata.json` | pinned RPC identity、接口描述和脱敏参数事实 | CLI identity、运行时路由、risk 推断 |
|
||||
| `schema_hints/selection/*.json` | reviewed selection prose(summary / use_when / avoid_when / examples) | 创建 Cobra 命令或参数、改写 safety |
|
||||
| typed parameter metadata / constraints | 由 Contract 约束投影而来的 `require_one_of` / 互斥等;以及仍需 reviewed 的 `required_when` 等 | 命令 identity |
|
||||
| `schema_parameter_mapping_ledger.go` | CLI flag 无直接 RPC property 的 exclusions / removals | 命令发现、risk 推断、创建 CLI flag;property 交付归 ParamDecl.Property |
|
||||
| leaf `Contract.Interface` / `ParamDecl` | 声明的 RPC identity 与 interface_* 事实(`schema_mcp_metadata.json` 已退役) | CLI identity、运行时路由、**创建 CLI flag**、risk 推断 |
|
||||
| ProductDecl + leaf `Contract.Selection` | reviewed selection / product routing prose(`contract_final`) | 创建 Cobra 命令或参数、改写 safety;`schema_hints/` 已退役 |
|
||||
| Skills/Markdown | 产品路由、工作流和使用建议 | 命令存在性和 flag 事实 |
|
||||
| `schema_catalog/`(catalog.json + tools/<product>.json)及其他 generated JSON | resolved registry 的兼容发布序列化;运行时由 production loader 解回 typed registry/index | generation/source resolution 输入、identity fallback、手工修复源 |
|
||||
| `cmd_schema_catalog` CI/local dump(可选 `schema_catalog/` / meta-index) | resolved registry 的兼容序列化快照,仅供 jq/determinism;不得提交为 runtime 来源 | production delivery、`ResolveMeta` 权威、identity fallback、手工修复源 |
|
||||
|
||||
`schema_command_registry.json` 承载 reviewed `CommandRegistry`。Manual command addition 先以确定性规则合并进 effective registry;从 binder 开始,下游只看到一个稳定 identity/navigation 模型。旧 wire 中的 `surface_hash` / `surface_tools` 字段仅为兼容名称,语义已经是 effective Registry hash/coverage,不构成第二事实源。
|
||||
identity collector 从 live Cobra leaves 的 `ContractFinal.Identity` 声明生成 `CommandSpec`,应用 reviewed exclusions 后按确定性规则索引为 effective registry;从 binder 开始,下游只看到一个稳定 identity/navigation 模型。reviewed `schema_command_registry/` 已退役,其历史角色(稳定 canonical identity/navigation 事实源)由 collector 承接。旧 wire 中的 `surface_hash` / `surface_tools` 字段仅为兼容名称,语义已经是 effective Registry hash/coverage,不构成第二事实源。
|
||||
|
||||
### 4.1 flag / help / schema 同源(路径 A + 嵌入)
|
||||
|
||||
完整决策、字段归属表、`HOM-*` 门禁规划与可选 MCP 透传准入条件见 [`flag-help-schema-homology.md`](flag-help-schema-homology.md)。
|
||||
|
||||
摘要:
|
||||
|
||||
- **同源面**:Contract → cobra flags ≡ `--help` Flags ≡ **嵌入注解后的** schema `parameters` / 关系约束;显式 `Risk` 经 `dws.schema.risk` overlay 进 Schema Safety。
|
||||
- **嵌入点**:`command.embedContractIntoSchema` 写入 `dws.schema.contract` / property / type / required;`AnnotateConstraints` 写入 constraints;Schema 组装(`runtimeToolSpecFromMetadata` / `ResolveSchemaBuild`)消费这些注解进入 typed `SchemaRegistry`(runtime assembly;非 `go:embed` catalog 交付)。
|
||||
- **硬规则**:CLI 表面事实 = **声明(Contract 数据字段)OR 人工标注**;禁止纯推断。非 CLI 表面字段(identity / selection / interface)必须有**评审源**。声明写法见同源文档 §1.2;标注见 §1.3;**`ToolSpec` 全字段权威见 RFC §5.0.4 / 同源 §1.4**。
|
||||
- **非同源面(有意)**:identity(collector)、selection 文案(ProductDecl / leaf `Contract.Selection`)、RPC 形状(MCP meta 仅 `interface_*`)、dry-run 正能力 registry。
|
||||
- **禁止**:以 MCP meta 为主通道生成 Leaf/Shortcut 的 flag;已退役的 hint overlay 改写 type/required/default;Schema 字段无权威归属。
|
||||
|
||||
## 5. 统一解析与 precedence
|
||||
|
||||
### 5.1 Identity
|
||||
|
||||
- Reviewed base `CommandRegistry` 是 stable canonical identity、primary path、alias 和 navigation 的唯一基础事实源。
|
||||
- Reviewed manual command addition 只能引用精确存在的 runnable Cobra leaf;它在绑定前合并进 `EffectiveCommandRegistry`。若与 base Registry 的 identity/path/alias 冲突,生成失败,不能按 precedence 静默覆盖。
|
||||
- identity collector(`CollectIdentitySpecs`)是 stable canonical identity、primary path、alias 和 navigation 的唯一基础事实源:每个携带 `ContractFinal.Identity` 的 runnable Cobra leaf(含 Hidden deprecated/migration shims)贡献一条 identity,reviewed exclusions 精确应用;reviewed `schema_command_registry/` 已退役,其历史角色由 collector 承接。
|
||||
- Collector 输出的 `CommandSpec` 在索引时 fail-closed 校验 canonical/product/path/alias/visibility 的合法性与唯一性;重复 identity、alias 复用 primary path 或 alias collision 全部失败,不能按 precedence 静默覆盖。
|
||||
- Binder 必须把 effective entry 的 primary path 和每个 alias 精确解析到同一个真实 executable leaf;stale path、phantom path、重复 identity 或 alias collision 全部失败。
|
||||
- Native identity annotation 是可选的一致性证据:存在时必须与 effective entry 精确一致;缺失不触发补写、推断或 fallback。
|
||||
- Public runnable Cobra leaf 未进入 effective registry 时,必须存在 exact、reviewed、带 reason 的 exclusion;不得用 prefix/wildcard 排除。
|
||||
@@ -154,30 +158,35 @@ DWS 当前对外仍保留兼容 wire:leaf 使用 flat `parameters`,安全和
|
||||
constraint、Cobra help 和 interface resolver 提供,因此删除该过渡层没有数据迁移缺口。
|
||||
CI 同时禁止重新加入 generated native contracts 或 materialization 入口。
|
||||
|
||||
#### CommandRegistry 输入审计
|
||||
#### Identity 输入审计(registry 已退役)
|
||||
|
||||
`schema_command_registry.json` 是 reviewed source,不是生成快照。它必须保留
|
||||
`$schema: ./schema_command_registry.schema.json`。该 JSON Schema 对 root、product
|
||||
和 CommandSpec 全部使用 `additionalProperties: false`,并约束:
|
||||
历史上 identity 来自 reviewed `schema_command_registry/`(`registry.json` +
|
||||
`products/*.json`,由随附 JSON Schema 校验)。该 reviewed registry 已退役,
|
||||
改由 identity collector 承接:identity 现在由 `CollectIdentitySpecs` 从 live
|
||||
Cobra 树的 `ContractFinal.Identity` 声明收集,输入契约即声明本身。严格 Go
|
||||
索引(`indexCommandSpecs`)继续 fail-closed 校验:
|
||||
|
||||
- canonical identity、`source_product_id` 和精确 CLI path 的格式;
|
||||
- `aliases` 唯一且不能复用 primary path;
|
||||
- `visibility` 只允许 `public | compat | internal`,省略时明确归一化为
|
||||
`public`;
|
||||
- primary path、alias、canonical 和 product 之间无法由 JSON Schema 表达的
|
||||
交叉约束,继续由 Go strict loader 和 Cobra binder fail-closed 校验。
|
||||
- primary path、alias、canonical 和 product 之间的交叉唯一性约束。
|
||||
|
||||
Registry semantic hash 覆盖 canonical、primary CLI path、alias 集合、
|
||||
Registry semantic hash 仍覆盖 canonical、primary CLI path、alias 集合、
|
||||
`source_product_id` 和 normalized visibility。格式、顺序以及省略的等价默认值
|
||||
不改变 hash;上述任一稳定契约字段变化都必须改变 hash。测试逐字段验证这一点,
|
||||
不使用当前命令数量作为常量。
|
||||
|
||||
普通 `go generate ./internal/cli` 只把 Registry 作为 validation-only 输入并生成
|
||||
Agent metadata/Catalog 等单向下游资产,不生成或覆盖 Registry。drift policy 在生成
|
||||
前后对 reviewed Registry 做 byte-for-byte guard;独立的
|
||||
`check-schema-command-registry.sh` 在 interface/provenance/Catalog policy 之前检查
|
||||
JSON 输入契约、禁用旧 native materialization 符号,并从 Registry 动态计算审计
|
||||
数量,不能硬编码某次快照的 tool count。
|
||||
普通 `go generate ./internal/cli` 只生成
|
||||
`param_aliases_generated.go`;production Catalog / `ResolveMeta` 由 runtime
|
||||
`ResolveSchemaBuild` 装配(`deliverySchemaCatalog` Once 后缓存 Meta 投影)。
|
||||
`cmd_schema_catalog` 仅按需打 CI/local dump;其 `InstallBuildTimeAgentMetadataJSON`
|
||||
inject 仅服务 dump,生产 Agent 权威仍是 leaf `ContractFinal` / `ProductDecl`。
|
||||
不写也不 embed `schema_agent_metadata/`。drift policy 禁止已退役的
|
||||
`schema_command_registry/` 在生成前后重新出现;原独立脚本
|
||||
`check-schema-command-registry.sh` 的 registry-agnostic side guards(禁用旧
|
||||
native materialization 符号、go:generate 单轨、lazy loader 纪律)已迁入
|
||||
`check-schema-catalog.sh`。
|
||||
|
||||
### 5.2 Parameter
|
||||
|
||||
@@ -186,15 +195,17 @@ JSON 输入契约、禁用旧 native materialization 符号,并从 Registry
|
||||
实现中的参数字段顺序固定为:
|
||||
|
||||
```text
|
||||
reviewed manual > versioned binding > command constraint > typed metadata
|
||||
> native/Cobra contract > ToolSchemaHint > MCP metadata
|
||||
> inference/default
|
||||
versioned binding > command constraint > typed metadata
|
||||
> native/Cobra contract (ParamDecl / ContractFinal)
|
||||
> MCP metadata > inference/default
|
||||
```
|
||||
|
||||
命令 `title` / `description` 使用独立但同样确定的文本顺序:
|
||||
|
||||
```text
|
||||
reviewed ToolSchemaHint > command-specific Cobra Help > MCP metadata > inference
|
||||
description: Cobra Long > ContractDecl description (contract_final) > MCP metadata > inference
|
||||
(Long 胜出时 provenance = cobra_help / cobra_help_preferred)
|
||||
title: ContractDecl / ContractFinal > Cobra Short > MCP metadata > inference
|
||||
```
|
||||
|
||||
因此多个 CLI leaf 复用同一个 RPC 时,通用 RPC 文案只能作为未选中的
|
||||
@@ -252,31 +263,24 @@ dws schema --all # 所有工具的完整 leaf 导
|
||||
|
||||
## 8. 生成与发布
|
||||
|
||||
当 Cobra、flag、identity、binding、manual hint、Agent hint 或 Skill 发生变化时:
|
||||
当 Cobra、flag、identity、binding、leaf `Contract` / ProductDecl 或 Skill 发生变化时:
|
||||
|
||||
1. 审核真实 Cobra 变化,确认命令和 flag 已实际存在。新增或修改稳定 command identity、primary CLI path 或 alias 时,精确编辑 reviewed `CommandRegistry`(当前持久化文件为 `schema_command_registry.json`)。参数、Skill 或 metadata 单独变化时不要机械改写 Registry,也不要从旧 Catalog 反向生成它。
|
||||
2. 仅对明确例外使用 reviewed manual command addition;它必须精确引用现有 runnable leaf、带 reason,并在生成时归一化进 `EffectiveCommandRegistry`。Native identity annotation 若存在,应作为与 Registry 一致的实现断言维护,而不是用来 materialize identity。
|
||||
3. 生成 Agent metadata:
|
||||
|
||||
```bash
|
||||
make generate-schema-agent-metadata
|
||||
```
|
||||
|
||||
4. 从统一 typed registry 生成最终 Catalog:
|
||||
1. 审核真实 Cobra 变化,确认命令和 flag 已实际存在。新增或修改稳定 command identity、primary CLI path 或 alias 现在通过 leaf Contract 的 `ContractFinal.Identity` 声明完成(identity collector 据此收集;reviewed `schema_command_registry/` 已退役)。参数、Skill 或 metadata 单独变化时不要机械改写 identity 声明,也不要从旧 Catalog 反向生成它。
|
||||
2. 不应进入稳定 Agent 契约的 public runnable leaf 使用 reviewed exclusions(exact path、带 reason)。Native identity annotation 若存在,应作为与 identity 声明一致的实现断言维护,而不是用来 materialize identity。
|
||||
3. 生成参数别名并验证运行时 Schema 组装。`go generate ./internal/cli` 只运行 `cmd_param_aliases`;`cmd_schema_catalog` 仅按需生成 CI/local dump。生产权威为 leaf `ContractFinal` / `ProductDecl`;CI dump 可经 `agent_metadata_inject.go` / `InstallBuildTimeAgentMetadataJSON` 在内存中注入 Agent metadata,不写 `schema_agent_metadata/`:
|
||||
|
||||
```bash
|
||||
make generate-schema
|
||||
go generate ./internal/cli
|
||||
# 可选:生成 artifacts/ 下的 CI/local dump
|
||||
make generate-schema-catalog
|
||||
```
|
||||
|
||||
也可以运行 `go generate ./internal/cli` 生成正常发布资产。生成文件包括:
|
||||
`cmd_schema_agent_metadata` 可保留为非交付工具/测试,但不是 `go:generate` 入口,也不应再作为发布步骤。
|
||||
|
||||
- `internal/cli/schema_agent_metadata/index.json`
|
||||
- `internal/cli/schema_agent_metadata/<product>.json`
|
||||
- `internal/cli/schema_agent_metadata_audit.json`
|
||||
- `internal/cli/schema_catalog/catalog.json`(全局信封 + Catalog)
|
||||
- `internal/cli/schema_catalog/tools/<product>.json`(每产品 leaf ToolSpecs,按产品分片以免并发 PR 冲突)
|
||||
生成文件只有 `internal/cli/param_aliases_generated.go`(参数别名生成物)。`cmd_schema_catalog` 的 Catalog 和 meta-index 是可选 CI/local dump,不是交付物,且不得写入或提交到 `internal/cli/`。
|
||||
|
||||
只编辑来源;不要手工编辑 Agent metadata 或 Catalog 输出。
|
||||
`schema_agent_metadata/`、`schema_agent_metadata_audit.json` 与 `schema_hints/` 已退役;若存在则 policy 失败。只编辑来源;不要手工编辑或提交 Catalog / meta-index dump。
|
||||
|
||||
## 9. Completeness 与 final-delivery invariant
|
||||
|
||||
@@ -284,19 +288,19 @@ dws schema --all # 所有工具的完整 leaf 导
|
||||
|
||||
- 每个 public runnable Cobra leaf 要么能通过最终 embedded `SchemaIndex` 查询,要么有 exact、reviewed、带 reason 的 exclusion。
|
||||
- 每个最终 canonical path、primary CLI path 和 alias 都必须解析到同一个可执行 leaf;不得有 phantom path 或 collision。
|
||||
- `EffectiveCommandRegistry`、`SchemaRegistry/SchemaIndex`、Agent metadata 和 Catalog canonical sets 必须精确一致,不能只比较 count。
|
||||
- `EffectiveCommandRegistry`、`SchemaRegistry/SchemaIndex` 与 Catalog canonical sets 必须精确一致(含组装时内存 inject 的 Agent metadata 语义),不能只比较 count。
|
||||
- Leaf payload、`--all` 中对应 tool 和 Catalog full tool 必须是同一个 resolved `ToolSpec` 的内容级等价投影,并通过 production loader round-trip。
|
||||
- overview/product/group summary 与 Catalog summary 必须等于同一个 `ToolSpec.ToSummaryPayload()`;alias 查询只允许 `cli_path` 和 `is_alias` 这两个视图字段变化。
|
||||
- 每个最终字段及 parameter field 的 provenance winner value 必须与 delivered value 精确一致;不能只验证 provenance source、count 或字段是否存在。
|
||||
- 每个 MCP `interface_ref` 必须在 pinned interface registry 精确存在;local/composite/unavailable 必须满足同一 conflict matrix。
|
||||
- `--all` 的 tool set 必须与最终 index 一对一,且每个工具包含完整参数契约。
|
||||
- 连续两次生成必须字节稳定,提交的生成物不得漂移。
|
||||
- **同源门禁(规划,见同源文档 §4)**:受管命令逐步满足 `HOM-P1`–`P3`(parameters ≡ cobra/Contract)、`HOM-S1`–`S3`(Safety/Risk 对齐)、`HOM-I1`(interface 不创建 flag)、`HOM-D1`(help ≡ schema parameters)。hints 不得作为 type/required/default 的 winner。
|
||||
|
||||
推荐本地验证:
|
||||
|
||||
```bash
|
||||
make generate-schema-agent-metadata
|
||||
make generate-schema-catalog
|
||||
make generate-schema
|
||||
./scripts/policy/check-generated-drift.sh
|
||||
./scripts/policy/check-schema-catalog.sh
|
||||
go test ./internal/cli ./internal/app ./internal/generator/... -count=1
|
||||
@@ -306,8 +310,12 @@ go test ./internal/cli ./internal/app ./internal/generator/... -count=1
|
||||
|
||||
- 运行时调用 MCP `tools/list` 或访问网络生成 Schema。
|
||||
- 从 `schema_catalog/` 等生成 JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
|
||||
- 重新引入 `schema_agent_metadata/`(或 audit JSON)作为交付物、`go:embed` 目标,或把它写回 `go:generate` 入口。
|
||||
- 从 MCP meta / 已退役的 `schema_mcp_metadata` **生成或补齐** LeafSpec/Shortcut 主路径的 CLI flag(interface overlay 除外);未满足同源文档 §5 准入条件时启用「MCP 透传生成通道」。
|
||||
- 把 native annotation、legacy registry 或 Catalog 当作 identity fallback;或在 `EffectiveCommandRegistry` 之后再次选择 identity winner。
|
||||
- renderer、query 或 gate 在 `SchemaRegistry` 之后重新读取 source 并做第二次 merge。
|
||||
- 用 prefix/wildcard exclusion 隐藏未来命令。
|
||||
- 让 manual hint、CommandRegistry 或 interface metadata 宣称一个不存在的命令、flag 或 RPC 可用。
|
||||
- 让 ProductDecl / leaf `Contract`、CommandRegistry 或 interface metadata 宣称一个不存在的命令、flag 或 RPC 可用。
|
||||
- 重新引入 `schema_hints/`(含 selection/metadata/imported/audit JSON)或任何 HintFile overlay,并在与 Contract/cobra 冲突时赢得 type/required/default。
|
||||
- 把 `schema --all` 当作普通业务数据查询,或把其完整结果无条件注入 Agent 上下文。
|
||||
- 将 LeafSpec、`+shortcut` 或 write-guard/cursor/多步命令注册为 `mcp_passthrough` 表面。
|
||||
|
||||
@@ -63,18 +63,18 @@
|
||||
|---|:---:|---|
|
||||
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
|
||||
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
|
||||
| **gap-buildable** | **42** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`、task `+related-tasks` |
|
||||
| covered-smart | 48 | 已建智能 shortcut / 部分覆盖 |
|
||||
| **gap-buildable** | **41** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`/`+chat-list`、task `+related-tasks` |
|
||||
| covered-smart | 49 | 已建智能 shortcut / 部分覆盖 |
|
||||
|
||||
## 🎯 gap-buildable 目标清单(原 49 条,已建 7 → 剩 42,按服务)
|
||||
## 🎯 gap-buildable 目标清单(原 49 条,已建 8 → 剩 41,按服务)
|
||||
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
|
||||
### im → chat(6)
|
||||
### im → chat(5)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+chat-list` | read | dws 有 list-my-groups/list-all-conversations 原子 tool,但无 types 枚举+bot剥p2p降级、无 exclude-muted 客户端过滤、无字段投影 |
|
||||
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` 当前页过滤 + `--exclude-muted` + page-size/page-token 别名 + openConversationId/name/conversationType 投影。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊 list_conversation_message_v2 / list_individual_chat_message 互斥 + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
| `+chat-search` | read | dws 无群名模糊搜索v2对应 tool(search_common_groups/find 语义不同),缺 query规范化、mode映射、mute过滤、meta投影 |
|
||||
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
|
||||
@@ -176,7 +176,7 @@
|
||||
|
||||
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
|
||||
|
||||
- **im**: +chat-members-list +messages-send +threads-messages-list
|
||||
- **im**: +chat-members-list +chat-list +messages-send +threads-messages-list
|
||||
- **task**: +complete +assign +get-my-tasks +get-related-tasks
|
||||
- **contact**: +search-user
|
||||
- **calendar**: +agenda +create +update +freebusy +suggestion
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
# Skill 分发/安装架构优化方案
|
||||
|
||||
> 目标:把 DWS Agent skill 的"装在哪、怎么装、装完记什么"从 7+ 个安装面的
|
||||
> N 份漂移拷贝,收敛为 **Go 侧单一事实源 + 单一安装引擎 + 脚本侧 bootstrap**。
|
||||
> 本文基于 feat/skill-mode-migration 工作区(multi 化落地后)的代码级盘点
|
||||
> (2026-08-05),所有锚点均可跳转验证。
|
||||
> 前置文档:[skill-multi-migration-plan.md](skill-multi-migration-plan.md)(下称《迁移计划》)、
|
||||
> [skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
|
||||
|
||||
## 1. 问题量化
|
||||
|
||||
### 1.1 复制矩阵(能力 × 面)
|
||||
|
||||
multi 化之后,同一块逻辑在各安装面的拷贝数(✓ = 独立拷贝一份,数字 = 份数):
|
||||
|
||||
| 能力 \ 面 | install.sh | install.ps1 | npm install.js | install-skills.sh | install-event.sh | install-devapp.sh(+ps1) | Homebrew caveats | `dws skill setup` (Go) | `dws upgrade` (Go) |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| agent home 清单 | ✓×3 | ✓ | ✓ | ✓×2 | ✓ | ✓×2 | — | ✓ | ✓ |
|
||||
| 互斥清理(mono↔multi) | ✓×2(其中 1 份死代码) | ✓ | ✓ | ✓ | — | — | — | ✓ | ✓ |
|
||||
| mode 解析(env/flag/交互) | ✓ | ✓ | ✓ | — | — | — | — | ✓ | 布局嗅探 |
|
||||
| stale `dingtalk-*` 清理 | ✓ | ✓ | ✓ | ✓ | — | — | — | ✗(additive,不清) | ✓ |
|
||||
| `~/.dws/skills` 缓存写 | ✓ | ✓ | ✓ | — | ✓ | ✓ | — | —(只读回退) | ✓(仅 multi) |
|
||||
| 安装状态(state.json) | 无 | 无 | 无 | 无 | 无 | 无 | — | 无 | 无 |
|
||||
|
||||
清单锚点(agent home 清单,全仓共 **15 份**):
|
||||
|
||||
| # | 位置 | 备注 |
|
||||
|---|---|---|
|
||||
| 1 | `internal/app/skill_setup.go:20-37` | `skillSetupAgentHomes`,16 项,**无 opencode** |
|
||||
| 2 | `internal/app/skill_command.go:119-141` | `agentSkillPaths`,17 项,含 opencode(`:129`) |
|
||||
| 3 | `internal/upgrade/paths.go:35-52` | `knownSkillDirs`,16 项,**无 opencode** |
|
||||
| 4 | `scripts/install.sh:370-386` | mono 安装循环内联清单 |
|
||||
| 5 | `scripts/install.sh:439-455` | multi 清单(**死代码**,见 1.2) |
|
||||
| 6 | `scripts/install.sh:516-532` | multi 清单(生效) |
|
||||
| 7 | `scripts/install.ps1:44-61` | `$AgentDirs` |
|
||||
| 8 | `build/npm/install.js:11-28` | `AGENT_DIRS` |
|
||||
| 9 | `scripts/install-skills.sh:166-182` | multi 清单 |
|
||||
| 10 | `scripts/install-skills.sh:247-261` | mono 清单 |
|
||||
| 11 | `scripts/install-event.sh:103-107` | 17 项,**含 `.config/opencode/skills`**(`:107`) |
|
||||
| 12 | `scripts/install-devapp.sh:87-91` | 含 opencode(`:91`) |
|
||||
| 13 | `scripts/install-devapp.ps1:106` | 含 opencode |
|
||||
| 14 | `test/scripts/package_script_test.go` | `expectedPackagedSkillTargets` |
|
||||
| 15 | `scripts/release/verify-package-managers.sh:75-76` | `HOME_AGENT_PARENTS` / `HOME_SKILL_TARGETS` |
|
||||
|
||||
互斥清理逻辑共 **≥8 份**:`skill_setup.go:570-608`、`paths.go:306-327` +
|
||||
`paths.go:163-179`(mono 分支内联)、`install.sh:394-399` + `install.sh:557-570`
|
||||
+ 死代码 `install.sh:477-486`、`install.ps1:488-498` + `install.ps1:562-570`、
|
||||
`install.js:130-137` + `install.js:165-178`、`install-skills.sh:207-220`。
|
||||
|
||||
mode 解析共 **4 份**:`skill_setup.go:343-376`、`install.sh:220-257`、
|
||||
`install.ps1:293-332`、`install.js:197-214`(各自实现 env 优先级、非法值报错、
|
||||
TTY 交互、非 TTY 默认值,措辞与行为细节已不完全一致)。
|
||||
|
||||
stale `dingtalk-*` 清理共 **5 份**:`paths.go:317-325`、`install.sh:561-567`、
|
||||
`install.ps1:562-570`、`install.js:168-172`、`install-skills.sh:211-220`。
|
||||
|
||||
缓存写共 **6 份**:`install.sh:326-344`(multi)+ `install.sh:349-360`(mono)、
|
||||
`install.ps1:446-471`、`install.js:221-237`、`install-event.sh:165-169`、
|
||||
`install-devapp.sh:82-84`、`paths.go:290-296`(upgrade 只刷 multi)。
|
||||
|
||||
### 1.2 已发生的漂移实例(不是假设,是现状)
|
||||
|
||||
1. **opencode 清单漂移**:`dws skill install` 支持 opencode 目标
|
||||
(`skill_command.go:129`),install-event / install-devapp 也往
|
||||
`~/.config/opencode/skills` 装(`install-event.sh:107`、
|
||||
`install-devapp.sh:91`),但 `dws skill setup --target all` 与
|
||||
`dws upgrade` 的清单都不含它(`skill_setup.go:20-37`、
|
||||
`paths.go:35-52`)。结果:opencode 用户能收到 event/dev skill,
|
||||
却永远收不到内置 skill 的安装与升级。
|
||||
2. **install.sh 死代码**:`install_multi_skills_to_homes` 定义了两次
|
||||
(`install.sh:434` 与 `install.sh:511`),`_install_multi_to_base`
|
||||
同样两次(`install.sh:473` 与 `install.sh:549`)。bash 后定义覆盖先定义,
|
||||
第一对(434-505)整体是死代码——本次 multi 化自己引入的重复。
|
||||
3. **"镜像"注释与实现已不符**:`install.sh:507-510` 注释声称 multi 安装
|
||||
"mirroring `dws skill setup --mode multi`",但脚本会删除 bundle 里不存在
|
||||
的 stale `dingtalk-*`(`install.sh:561-567`),而 setup 是 additive 语义、
|
||||
只清 `dws/`(`skill_setup.go:587-593`)。注释说镜像,行为已分叉。
|
||||
4. **互斥语义自相矛盾**:install-event.sh 把 multi 的 `dingtalk-event` 和
|
||||
mono 的 `dws` **同时**装进同一批 agent home(`install-event.sh:171-172`),
|
||||
与其他所有面"mono/multi 互斥"的语义直接冲突。
|
||||
5. **缓存只写不读、版本不可见**:`dws skill setup` 默认走二进制 embed
|
||||
(`skill_setup_embed.go:50-59`),`~/.dws/skills` 只是 legacy 回退候选
|
||||
(`skill_setup.go:442-447`);6 个写入方没有任何版本戳,upgrade 只刷
|
||||
multi 不刷 mono(`paths.go:292-296`),缓存与 embed 漂移不可见。
|
||||
6. **保留前缀规则已双份**:`dingtalk-` / `dws-shared` 的保留约定在
|
||||
`skill_setup.go:199`(`multiSkillPrefix`)、`skill_setup.go:205`
|
||||
(`multiSharedSkill`)与 `paths.go:332-334`(`isMultiSkillDirName`)
|
||||
各写一份,互斥清理依赖"市场 skill 不用该前缀"这一隐性约定。
|
||||
|
||||
### 1.3 维护成本论证(本次 multi 化实证)
|
||||
|
||||
本次"把 7 个面全部 multi 化"这一个语义变更,工作区改动为 **13 个文件、
|
||||
+759/−166 行**(`git diff --stat`),横跨 Go / POSIX sh / PowerShell /
|
||||
JavaScript 四种语言:
|
||||
|
||||
| 文件 | 改动量 | 改了什么 |
|
||||
|---|---|---|
|
||||
| `scripts/install.sh` | +256/−… | mode 解析、multi 安装×2(含死代码)、互斥清理、缓存写 |
|
||||
| `scripts/install.ps1` | +169 | 同上,PowerShell 再写一遍 |
|
||||
| `build/npm/install.js` | +87 | 同上,JavaScript 再写一遍 |
|
||||
| `scripts/install-skills.sh` | +101 | multi 安装 + stale 清理 |
|
||||
| `internal/upgrade/paths.go` | +206 | upgrade multi 刷新 + 互斥清理 + 缓存 |
|
||||
| `internal/app/skill_setup.go` 等 Go 侧 | ~+50 | setup multi 语义 |
|
||||
| 测试与文档 | 其余 | 4 个测试文件 + README×2 + SKILL.md |
|
||||
|
||||
即:**一个语义变更 = 4 种语言 × 7+ 处同步编辑**,且仍然漏了 opencode、
|
||||
制造了死代码、留下了语义分叉(1.2)。新增一个 agent home 今天要改 15 份
|
||||
清单中的至少 11 份(另 4 份是测试/校验)。这不是"以后会漂移",而是
|
||||
**每一次改动都在当场制造漂移**。
|
||||
|
||||
## 2. 目标架构
|
||||
|
||||
### 2.1 分层总览
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ L3 分发面(7 个,全部退化为 bootstrap) │
|
||||
│ install.sh / install.ps1 / npm install.js / install-skills.sh /│
|
||||
│ install-event.sh / install-devapp.sh / Homebrew caveats │
|
||||
│ 职责:装二进制 → exec `dws skill setup --mode X --yes` │
|
||||
│ └─ 失败 → 冻结的内嵌 fallback 拷贝(一档,不再演进) │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ L2 单一安装引擎(Go,唯二入口) │
|
||||
│ dws skill setup ─┐ │
|
||||
│ dws upgrade ─┴─► 共用同一 install 实现(含互斥清理/记账) │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ L1 单一事实源:internal/skillhome(新共享包) │
|
||||
│ AgentHomes() 清单 + 布局规则(mono→<home>/dws,multi→平铺) │
|
||||
│ + 互斥/ stale 清理规则 + 保留前缀常量 + state.json 读写 │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ L0 事实数据 │
|
||||
│ //go:embed skills/{mono,multi}(skills_embed.go:28,默认源) │
|
||||
│ ~/.dws/skills/state.json(权威安装状态) │
|
||||
│ ~/.dws/skills/{mono,multi}(显式回退源,带版本戳,唯一写方=Go) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
▲ 门禁:scripts/policy/check-agent-homes-sync.sh(进 make policy)
|
||||
比对各脚本可解析清单块 ↔ skillhome 导出清单
|
||||
```
|
||||
|
||||
### 2.2 单一事实源:`internal/skillhome` 共享包
|
||||
|
||||
新建 `internal/skillhome`,把今天散在 3 处 Go 代码里的规则合并导出,
|
||||
setup / upgrade / 测试共用(对应《迁移计划》P0c-1):
|
||||
|
||||
| 导出物 | 收敛的现有拷贝 |
|
||||
|---|---|
|
||||
| `AgentHomes()`(含 opencode,带"首项必装/其余父目录门控"元数据) | `skill_setup.go:20-37`、`paths.go:35-52`,并与 `skill_command.go:119-141` 互相断言 |
|
||||
| `HomeForMode(base, mode)` 布局规则 | `skill_setup.go:502-507`(`agentHomeForMode`) |
|
||||
| `MutualExclusionVictims(home, mode)` / stale 判定 | `skill_setup.go:570-596`、`paths.go:306-327` |
|
||||
| `ReservedSkillPrefixes` / `ReservedSkillNames` 常量 | `skill_setup.go:199/205`、`paths.go:332-334` |
|
||||
| `State` 读写(state.json,见 2.4) | 新增(《迁移计划》P0b-1) |
|
||||
|
||||
脚本侧不生成代码(sh/ps1/js 三语言片段生成成本高、措辞差异大),改为
|
||||
**可解析标记块 + policy 比对**:每个脚本的清单包在
|
||||
`# DWS-AGENT-HOMES-BEGIN` / `# DWS-AGENT-HOMES-END` 注释块内,新增
|
||||
`scripts/policy/check-agent-homes-sync.sh` 提取 7 个脚本块与
|
||||
`skillhome` 导出清单(`go run ./internal/skillhome/cmd/dump` 或
|
||||
`dws skill setup --print-agent-homes` 之类调试出口)逐一比对,挂进
|
||||
`make policy`(Makefile:86-97 现有政策链)。对应《迁移计划》P0c-2。
|
||||
|
||||
### 2.3 安装逻辑单引擎化:脚本退化为 bootstrap
|
||||
|
||||
**终态形态**:每个安装脚本只做两件事——装二进制、执行
|
||||
`dws skill setup --mode <resolved> --yes`(mode 解析仍允许脚本做,因为它
|
||||
要处理自己的 env/flag;也可以更进一步把 `DWS_SKILL_MODE` 透传给 setup)。
|
||||
执行失败(二进制跑不起来、setup 非 0 退出)时,降级到**冻结的内嵌
|
||||
fallback 拷贝逻辑一档**——即今天的拷贝实现原样保留但标记"不再演进",
|
||||
语义变更只改 Go 引擎。
|
||||
|
||||
各面降级可行性分析:
|
||||
|
||||
| 面 | 二进制可得性 | 可行性 | 风险与对策 |
|
||||
|---|---|---|---|
|
||||
| install.sh | `main` 先 `install_binary` 后 `install_skills`(`install.sh:830-831`),二进制就在 `$INSTALL_DIR` | ✅ 直接调 `"$INSTALL_DIR/dws" skill setup --mode "$SKILL_MODE" --yes` | `DWS_SKILLS_ONLY=1` 时不装二进制 → 先 `command -v dws`,无则走 fallback。curl 下载不带 quarantine 属性,macOS 可直接执行。风险低 |
|
||||
| install.ps1 | 同序(`Install-Binary`:337 → `Install-Skills`:622,main 入口 `:700`) | ✅ 调 `& $installDir\dws.exe skill setup ...` | ExecutionPolicy 约束的是 .ps1 脚本本身,**子进程 dws.exe 不受其限制**;AppLocker/WDAC 环境可能拦未签名二进制——但那种环境 dws 本身也跑不了,fallback 拷贝仍必要。风险低-中 |
|
||||
| npm install.js | postinstall 已把平台二进制解到 `vendor/`(`install.js:260`)再装 skill(`:271-280`) | ✅ `execFileSync(path.join(vendorDir,'dws'), ['skill','setup','--mode',m,'--yes'])` | `npm i --ignore-scripts` 时 postinstall 整体不跑(现状如此,非新增风险);部分 CI 以 root 跑生命周期脚本权限怪异;Windows 用 `dws.exe`。风险中,fallback 必须保留 |
|
||||
| install-skills.sh | skills-only 面,不装二进制 | ✅ `command -v dws` 有则调引擎,无则 fallback | 本质是"刷新 skill"路径,有 dws 才谈得上刷新。风险低 |
|
||||
| install-event.sh | 装二进制 + 单 skill(dingtalk-event + mono dws) | ⚠️ 有条件 | `EVENT_VERSION` 可与已装二进制版本不同(尤其 `DWS_SKILLS_ONLY=1`),embed 与目标 zip 可能错配;且当前同时装 mono+multi 的语义(`:171-172`)需先按 1.2-4 收敛。**最后迁移**,迁移前先把 dingtalk-event 纳入 embed 并改成 `-s event` 调引擎 |
|
||||
| install-devapp.sh(+ps1) | 同上(dingtalk-dev) | ⚠️ 有条件 | 同 install-event。风险中-高,最后迁移 |
|
||||
| Homebrew caveats | 只打印提示 | ✅ 已是终态 | `homebrew.rb.tmpl:33-36` 现状就是"Run `dws skill setup`",无需改,是其他面的样板 |
|
||||
|
||||
配套收益:《迁移计划》P2-2(sh/ps1 的 multi 分支真装,1.5d)与 P2-3
|
||||
(install.js / install-skills.sh 加 mode 支持,1d)在单引擎方案下**大幅
|
||||
缩水**——脚本只需把 mode 透传给引擎,不再在 sh/ps1/js 里写安装逻辑。
|
||||
|
||||
### 2.4 状态与缓存
|
||||
|
||||
- **state.json 权威化**(《迁移计划》P0b 原样采纳):
|
||||
`~/.dws/skills/state.json` 记录 `schema_version / mode / cli_version /
|
||||
installed / agent_homes / previous`,写方只有 `dws skill setup` 与
|
||||
`dws upgrade`(脚本侧不再各自写状态——它们调引擎,引擎记账);缺失/损坏
|
||||
时按磁盘形态反推(有 `dws/` → mono;有 `dingtalk-*` → multi;都有 →
|
||||
报 drift 要求显式收敛)。
|
||||
- **`~/.dws/skills` 缓存收敛**:二选一,本文建议后者——
|
||||
1. ~~被 state 取代,删除缓存~~:激进;embed 之外的"无源码机回退源"场景
|
||||
(`skill_setup.go:442-447` 注释描述的场景)会断。
|
||||
2. **明确为 setup 回退源 + 版本戳**(采纳):缓存目录写入
|
||||
`cli_version` 戳文件(或并入 state.json 的 `cache_version` 字段);
|
||||
**写缓存收敛到 Go 侧唯一实现**(setup/upgrade 共用),install.sh /
|
||||
ps1 / js / event / devapp 的 6 份缓存写逻辑(1.1 矩阵)随 P1/P3 删除;
|
||||
upgrade 补齐 mono 缓存刷新(修 `paths.go:292-296` 只刷 multi 的不对称)。
|
||||
mono 下线版本再评估整体废弃缓存。
|
||||
|
||||
### 2.5 市场 skill 边界正式化
|
||||
|
||||
把"`dingtalk-*` 前缀与 `dws-shared` / `dws` 名称为 DWS 内置保留"从隐性约定
|
||||
变成共享常量 + 测试:
|
||||
|
||||
- `skillhome.ReservedSkillPrefixes = ["dingtalk-"]`、
|
||||
`skillhome.ReservedSkillNames = ["dws-shared", "dws"]`,替换
|
||||
`skill_setup.go:199/205` 与 `paths.go:332-334` 两份私有拷贝;所有互斥/
|
||||
stale 清理只认这两个常量。
|
||||
- 测试一:扫描 `skills/multi/` 实际目录名,断言全部命中保留规则(防新增
|
||||
产品 skill 破坏前缀约定)。
|
||||
- 测试二:市场 skill 安装路径(`skill_command.go:488-498` 的目标解析)若
|
||||
产物名命中保留名则拒绝安装——把"市场无同名前缀"从祈祷变成门禁。
|
||||
|
||||
## 3. 迁移步骤(渐进、不破坏存量用户)
|
||||
|
||||
| 阶段 | 内容 | 风险 |
|
||||
|---|---|---|
|
||||
| **P0 清单 + policy** | 建 `internal/skillhome`:AgentHomes(**补 opencode**,修 1.2-1)、布局/互斥/保留名常量;setup/upgrade 切到共享包;脚本清单包标记块;新增 `check-agent-homes-sync.sh` 进 `make policy`;删 install.sh 死代码(1.2-2) | 低。纯收敛不改行为;policy 脚本初期可能误报 → 先 warn-only 跑一个版本再转 hard-fail |
|
||||
| **P1 setup 单引擎 + 脚本降级** | setup/upgrade 安装实现合一(互斥清理、stale 语义统一为"引擎内一种",修 1.2-3);install.sh / install.ps1 / install.js / install-skills.sh 改为 bootstrap + 冻结 fallback;install-event/devapp 暂不动 | 中。bootstrap 首版要在三语言 CI 矩阵上验证"引擎失败→fallback"链路;fallback 标记冻结,防止继续演进出第 9 份拷贝 |
|
||||
| **P2 state 权威 + 缓存收敛** | state.json 读写进 skillhome,setup/upgrade 记账;缓存加版本戳、写方收敛到 Go、upgrade 补刷 mono 缓存;install-event/devapp 语义收敛(mono+multi 双装改为引擎语义)后同样 bootstrap 化 | 中。存量机器无 state → 磁盘反推逻辑必须有故障注入测试;event/devapp 版本错配场景需保留 zip 直装逃生门 |
|
||||
| **P3 删除遗留拷贝** | 删除:脚本侧 6 份缓存写、install.sh/ps1/js 内被引擎接管的安装函数(保留冻结 fallback 一档)、Go 侧 `mutualExclusionVictims` 等被 skillhome 吸收的私有拷贝;观察一个版本后评估 fallback 是否可再降档 | 低-中。每删一处先确认 policy 与测试不再引用;fallback 删除是独立决策,不与本阶段捆绑 |
|
||||
|
||||
与《迁移计划》的先后关系:本文 P0 即计划 P0c,应**最先做**;P1 与计划
|
||||
P0a/P0b 并行不冲突(引擎合一会让 P0a 的 mode-aware upgrade 少写一份代码);
|
||||
P2 吸收计划 P0b;P3 在计划 P2 切默认之后执行。
|
||||
|
||||
## 4. 明确不做
|
||||
|
||||
- **不重写成 symlink canonical**(canonical 一份 + 各 agent home 软链):
|
||||
已随生态分发通道一并评估否决,决策与实测原因见
|
||||
《迁移计划》[§7.3](skill-multi-migration-plan.md)(无版本固定、依赖
|
||||
Node/GitHub 可达、mono 会被一起发现、悟空 bundled/离线预装覆盖不了;
|
||||
其中悟空分发线已于 2026-08-05 下线,该条约束随之消失)。
|
||||
本地 symlink 模式(`setup --link`)同样维持计划 §8.2"可选/后置"定位,
|
||||
不在本方案内。
|
||||
- **不动 `dws-skills.zip` 产物布局**:zip 根 mono 副本 + `mono/` + `multi/`
|
||||
双树保持不变(`scripts/release/post-goreleaser.sh:220-248`)。
|
||||
- **不动市场 skill**:`dws skill install` 的安装语义、目标解析
|
||||
(`skill_command.go:488-498`)不变;2.5 只新增保留名拒绝门禁与测试,
|
||||
不改变既有安装行为。
|
||||
- **不删 fallback**:脚本侧内嵌拷贝逻辑降级为冻结档保留,不追求"脚本零
|
||||
拷贝"的纯净化。
|
||||
|
||||
## 5. 收益/成本与任务映射
|
||||
|
||||
### 5.1 收益/成本表
|
||||
|
||||
| 项 | 现状 | 目标 | 量化收益 |
|
||||
|---|---|---|---|
|
||||
| agent home 清单 | 15 份拷贝,已漂移(opencode) | 1 份 Go 源 + policy 比对 | 新增 agent 从"改 11 处"变"改 1 处" |
|
||||
| 安装/互斥/stale 逻辑 | ≥8 份,4 种语言,语义已分叉 | 1 个 Go 引擎 | 语义变更从 4 语言 × 7 面(本次实证 13 文件 +759 行)变 1 包 |
|
||||
| mode 解析 | 4 份,行为细节不一 | 脚本只做透传,引擎一处实现 | 非法值/默认值行为天然一致 |
|
||||
| 缓存写 | 6 份,无版本戳,只写不读 | Go 唯一写方 + 版本戳 | 缓存漂移可观测、可判废 |
|
||||
| 安装状态 | 无 | state.json 权威 | upgrade 粘性、回滚、drift 检测的前提 |
|
||||
| 市场边界 | 隐性约定,2 份前缀拷贝 | 共享常量 + 2 个测试 | 互斥清理误伤市场 skill 的风险归零 |
|
||||
| 成本 | — | P0≈1.5d,P1≈3-4d,P2≈2d,P3≈1d | 合计 7.5-8.5 人日;其中 P0/P2 与《迁移计划》P0b/P0c 重叠,净新增约 4-5 人日 |
|
||||
|
||||
### 5.2 与《迁移计划》§8 任务 ID 映射
|
||||
|
||||
| 本文阶段 | 对应计划任务 | 关系 |
|
||||
|---|---|---|
|
||||
| P0 清单 + policy | P0c-1(清单下沉共享包+补 opencode)、P0c-2(check-agent-homes-sync.sh) | 原样采纳,包名定为 `internal/skillhome` |
|
||||
| P1 引擎合一 | P0a-1(UpgradeSkillLocations mode-aware)的前置简化 | 引擎合一后 P0a-1 不复写互斥/清理逻辑 |
|
||||
| P1 脚本 bootstrap | **取代** P2-2(sh/ps1 multi 真装,1.5d)、P2-3(install.js/install-skills.sh mode 支持,1d) | 脚本不再写安装逻辑,两个任务缩水为"透传 mode + 调引擎 + 冻结 fallback",合计从 2.5d 降至 ~1d |
|
||||
| P2 state 权威 | P0b-1(state.json schema+setup 写入)、P0b-2(磁盘反推) | 原样采纳;缓存版本戳与写方收敛为本文新增 |
|
||||
| P2 event/devapp 收敛 | 新增(修 1.2-4 语义矛盾) | 依赖 P1 引擎稳定 |
|
||||
| P3 删除遗留 | 新增 | 在计划 P2 切默认之后执行 |
|
||||
| (不在本文) | P0-3 备份式安装、P0-4 请求头、P1-1 `dws skill mode`、P1-2/P2-1 默认翻转、P2-4 文案 | 仍按计划执行,与本文正交;备份式安装落地时直接写进引擎,天然覆盖所有面 |
|
||||
@@ -0,0 +1,231 @@
|
||||
# Skill 能力缺口分析与补全计划
|
||||
|
||||
> 基于 `feat/skill-mode-migration` 工作区代码级盘点(2026-08-05)。本工作区已把
|
||||
> install / upgrade / setup 的默认形态翻转为 multi;本文回答两个问题:
|
||||
> **管理能力上还缺什么**(Part 1)、**multi 内容是否覆盖 mono**(Part 2),
|
||||
> 并给出优先级排序的实施清单(Part 3)。
|
||||
> 任务 ID 复用 [skill-multi-migration-plan.md](skill-multi-migration-plan.md) §8.2
|
||||
> (P0a/P0b/P0c/P0-3/P0-4/P1-1/P1-2/P2-x/W4);新增项以 `P1-3`、`C1–C7` 编号。
|
||||
> 机制背景见 [skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
|
||||
|
||||
## 0. 结论速览
|
||||
|
||||
- **管理面**:默认翻转已落地(setup/四个安装脚本/upgrade 识 multi),但生命周期
|
||||
能力仍缺 6/8 项:无状态(state.json)、无备份回滚、无卸载、无故障恢复、
|
||||
upgrade 不按已装清单做增量、市场/内置边界靠前缀约定。当前形态 = "能装上
|
||||
multi,但装成什么样、出了事怎么退,全靠运气"。
|
||||
- **内容面**:multi 对产品参考的覆盖**总体是 mono 超集**(chat/event/dev/sheet
|
||||
均更详细),但 mono 有 **4 块全局能力在 multi 完全缺失**(recovery 闭环、
|
||||
确认门禁协议、Schema 渐进查询教学、LICENSE/NOTICE),另有 1 个脚本
|
||||
(`report_inbox_today.py`)未迁移;multi 自身还有 6 项内部不一致(死链、
|
||||
orphan 脚本、漏改 EXPERIMENTAL 文案等)。
|
||||
- **工作量**:管理面 P0–P2 剩余 ≈ 10.5–13 人日;内容补全 C1–C7 ≈ 3.5–4 人日;
|
||||
合计 ≈ 14–17 人日,与 plan §8.1 的 13–17 人日口径一致(内容项是新增量)。
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — 管理能力缺口(skill 生命周期管理)
|
||||
|
||||
### 1.1 现状盘点(本工作区实际状态)
|
||||
|
||||
**`dws skill setup`(内置 skill 安装)**
|
||||
|
||||
| 能力 | 状态 | 锚点 |
|
||||
|---|---|---|
|
||||
| mode 选择 | mono / multi;无 `--mode` 时 TTY 交互(multi 为默认项)、非 TTY 默认 multi | `internal/app/skill_setup.go:343-376` |
|
||||
| 按产品挑选 | `-s/--skill`、`-x/--exclude` 互斥;`dws-shared` 强制包含;未点名的已有 `dingtalk-*` 保留(additive) | `skill_setup.go:101-102`、`254-318`、`209-226`、`640-670` |
|
||||
| `--dry-run` | 有,预览 mode/来源/目标/子 skill,不写文件(root 持久 flag 注入,`internal/app/flags.go:43`) | `skill_setup.go:156-167` |
|
||||
| 源 | `--source` / `DWS_SKILL_SOURCE` 显式覆盖(失败不回退)→ 默认 embed(与二进制同版) | `internal/app/skill_setup_embed.go:50-58`、`skills_embed.go:28` |
|
||||
| 目标 | 16 个 agent home,父目录门控;`--target all` 不含 opencode,但命名 target 含(不对称) | `skill_setup.go:20-37`、`509-522` vs `internal/app/skill_command.go:129` |
|
||||
| 互斥清理 | 装 mono 删 `dingtalk-*`+`dws-shared`,装 multi 删 `dws/`;**best-effort,失败仅 warning 继续装** | `skill_setup.go:570-608` |
|
||||
| 备份/记账 | **无**。`RemoveAll` 直删,不写任何状态 | `skill_setup.go:616`、`655` |
|
||||
|
||||
**`dws skill search / get / install`(市场 skill)**
|
||||
|
||||
- 子命令全集仅 `get / install / search / setup`(+ 隐藏的 find/add 兼容提示),
|
||||
**无 status / mode / remove / rollback**(`skill_command.go:202-209`)。
|
||||
- `install` 解压到 agent skills **根目录**(非 `dws/` 子目录),无 mode 概念、
|
||||
无记账、无覆盖保护(`skill_command.go:378-443`、`653-672`)。
|
||||
|
||||
**`dws upgrade`(skill 刷新)**
|
||||
|
||||
- `LocateSkillsRoot` **优先 zip 内 `multi/`**(`internal/upgrade/paths.go`,
|
||||
接线于 `internal/app/upgrade.go`);`UpgradeSkillLocations` **包驱动**:有
|
||||
multi 树则始终 multi 刷新,否则 legacy mono 回退(不做磁盘粘性)。
|
||||
- multi 路径:平铺刷新 `<agent>/dingtalk-*`+`dws-shared`,清 `dws/` 残留与过期
|
||||
`dingtalk-*`,best-effort 刷 `~/.dws/skills/multi`(sibling `mono/` 存在时
|
||||
亦刷 mono 缓存)。
|
||||
- **有 multi 包时存量 mono 一次性迁 multi**(2026-08-05 owner:升级不做粘性;
|
||||
非运行时 mode-switch 产品,无确认/备份/state)。
|
||||
- skill 安装发生在**二进制替换之后**,skill 失败时二进制已换 → 半升级态。
|
||||
`dws upgrade --rollback` 只回滚二进制。
|
||||
|
||||
**安装脚本(7 面)**
|
||||
|
||||
| 面 | 本工作区状态 |
|
||||
|---|---|
|
||||
| `scripts/install.sh` | 已默认 multi 且**真装**(`install_multi_skills_to_homes`),mono 为 opt-in;`rm -rf` 直删无备份 |
|
||||
| `scripts/install.ps1` | 同上(Windows) |
|
||||
| `scripts/install-skills.sh` | 已加 `DWS_SKILL_MODE`(默认 multi)+ multi 安装 |
|
||||
| `build/npm/install.js` | 已加 `installMultiSkillsToHomes`(互斥清理 + 平铺) |
|
||||
| Homebrew formula | 不铺 agent home,caveats 引导 `dws skill setup` |
|
||||
| `scripts/install-event.sh` / `install-devapp.sh` | 单 skill 专项语义,缓存 `multi/dingtalk-event` / `multi/dingtalk-dev` 子集 |
|
||||
| 共同缺口 | **均不写 state.json、均无备份**;agent home 清单仍是 sh/ps1/js/Go 多份手写(`paths.go:23-52` 的 keep-in-sync 注释约定,无门禁) |
|
||||
|
||||
### 1.2 缺口表(对照完整生命周期)
|
||||
|
||||
严重度:🔴 高(可致数据丢失/双份派发/半装态)|🟡 中(能力缺失但有绕行)|🟢 低(体验项)
|
||||
|
||||
| # | 生命周期项 | 现状 | 严重度 | 补全设计(复用 plan §8 ID) |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **status 查询** | **无**。无 state.json、无 `dws skill mode/status`;判断 mode 只能人工看磁盘形态(`dws/` vs `dingtalk-*`) | 🔴 | **P0b-1** 新增 `~/.dws/skills/state.json`(schema_version/mode/cli_version/installed/agent_homes/previous),setup 与安装脚本写入;**P0b-2** 缺失/损坏时磁盘形态反推,双形态并存 → drift 报错;**P1-1** `dws skill mode`(status 子命令展示 mode/版本/已装列表/上次切换/备份) |
|
||||
| 2 | **切换** | 有(`setup --mode`),但**无状态、无记账**:切完不留 previous,互斥清理失败仅 warning 继续装(`skill_setup.go:600-608`)→ 可能 mono+multi 双份共存、Agent 双份派发 | 🔴 | **P0b** 记账(含 `previous`);**P0-3** 把「清理失败继续装」改为「失败整体回滚」;**P1-1** `mode set <mode>` 复用 setup 安装实现 + 备份 + 记账,成功后提示重启 AI 工具 |
|
||||
| 3 | **回滚** | **无备份**。setup/upgrade/安装脚本全部 `RemoveAll`/`rm -rf` 直删(`skill_setup.go:616,655`、`paths.go:181,249,307`、`install.js` `fs.rmSync`);`upgrade --rollback` 只回二进制 | 🔴 | **P0-3** 备份式安装:`RemoveAll` → `mv` 到 `~/.dws/skills/backup/<ts>-<mode>/`,保留最近 2 份,任一 home 失败自动恢复并非 0 退出;**P1-1** `mode rollback` 一条命令回到 `state.previous` |
|
||||
| 4 | **版本对齐** | embed 天然同版(setup 默认源,`skill_setup_embed.go:50-58`)✓;但 `~/.dws/skills` 缓存**只写不读**(仅 legacy 回退候选,`skill_setup.go:445-447`),upgrade 只刷 multi 缓存不刷 mono(`paths.go:290-296`),漂移不可见 | 🟡 | **P0b-1** state.json 记 `cli_version` 使漂移可见;**P0a-1** upgrade 按 mode 同步刷新对应缓存(或评估废弃缓存,plan §6 风险表末行) |
|
||||
| 5 | **卸载** | **无 remove**。skill 子命令仅 get/install/search/setup(`skill_command.go:202-209`);用户只能手动删目录,且不知道该删哪些(16 个 home × N 个 skill) | 🟡 | **新增 P1-3** `dws skill remove`:按 state.json 的 `agent_homes`×`installed` 精确删除内置 skill(`dingtalk-*`+`dws-shared`+`dws/`),不动市场 skill;`--dry-run` 预览(依赖 P0b) |
|
||||
| 6 | **局部更新** | setup 侧 additive 语义完整(`-s/-x`,未点名保留);但 **upgrade 增量语义未按 `state.installed`**:`UpgradeSkillLocations` 用 zip 内 bundle 全集刷新(`paths.go:135-137`、`339-358`),用户 `-x` 排除过的 skill 会被升级装回来 | 🔴 | plan §8.3-1 已定死语义(以 `state.installed` 为准增量刷新、未装不补装);**P0b-1** 先落 `installed` 列表,**P0a-1** `UpgradeSkillLocations(dir, mode)` 按其过滤 |
|
||||
| 7 | **故障恢复** | **无**。setup 清理/拷贝失败仅 warning 或按 home 跳过,留半装态;upgrade 的 skill 失败发生在二进制替换之后(`upgrade.go:593-611`),半升级态无自动恢复、无修复命令 | 🔴 | **P0-3** 备份式安装 + 失败整体回滚(改变「warning 继续装」语义,需同步改 `skill_setup_full_coverage_test.go` 多处断言,plan §8.3-2);**P0b-2** drift 检测给显式收敛指令;**P1-1** `mode rollback` |
|
||||
| 8 | **市场 vs 内置边界** | **隐性前缀约定**:互斥清理按 `dingtalk-`/`dws-shared` 名称扫描(`paths.go:332-334`、`skill_setup.go:581`),无 SKILL.md frontmatter 校验 —— 市场 skill 若同名前缀会被误删;反向地,市场 `install` 解压无保护,可覆盖内置 `dingtalk-*`(`skill_command.go:653-672`) | 🟡 | plan §6 风险行:清理前校验目录内 SKILL.md frontmatter 属 DWS 产品集并写成测试(落入 **P0-3** 的清理改造);长期由 state.json 的 `installed` 清单取代前缀扫描(P0b 后续) |
|
||||
|
||||
**附:本工作区已完成项**(不再列入缺口):setup 默认 multi(P2-1)、
|
||||
install.sh/ps1 真装 multi(P2-2)、install-skills.sh/install.js mode 支持
|
||||
(P2-3)、README×2 与 dingtalk-skill 文案(P2-4 部分)、upgrade 识别并刷新
|
||||
multi 包(P0a 的布局识别一半)。**尚未做**:P0c 清单收敛、P0b state.json、
|
||||
P0-3 备份、P0a 的 mode-aware 一半、P0-4 请求头、P1-1 mode 命令、P1-2 beta 轨。
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — 内容能力对等(mono vs multi)
|
||||
|
||||
### 2.1 树对比总览
|
||||
|
||||
| 维度 | mono | multi |
|
||||
|---|---|---|
|
||||
| 规模 | 152 文件 / ≈2.5 MB | 244 文件 / ≈3.3 MB(19 个 `dingtalk-*` + `dws-shared`) |
|
||||
| 入口 | 单 `SKILL.md`(332 行,含全局路由/危险表/Schema 教学) | 每产品一个 `SKILL.md` + `dws-shared` 全局契约(90 行) |
|
||||
| 全局参考 | `references/` 10 项(intent-guide、global-reference、url-patterns、error-codes、capability-limits、channel-login、field-rules、recovery-guide、best_practices/、products/) | `dws-shared/references/` 9 项 + 各产品 skill 自带 |
|
||||
| 脚本 | `scripts/` 37 个 | 10 个 skill 带 `scripts/` 共 55 个 |
|
||||
| 最佳实践 | `best_practices/` 01–11 + lite + `_common` | 按产品分发(01→chat … 11→minutes),`_common` 与 lite 入 `dws-shared` |
|
||||
|
||||
### 2.2 产品参考覆盖核对
|
||||
|
||||
逐产品比对结论:**multi 覆盖 mono 全部产品参考,且多数为超集**。
|
||||
|
||||
| mono 产品参考 | multi 对应物 | 结论 |
|
||||
|---|---|---|
|
||||
| aitable.md + aitable/(20)+ aitable-record-ops | `dingtalk-aitable`(同 20 子章节 + field-rules + 06-data-analytics) | ✅ 超集 |
|
||||
| chat.md + chat-emoji-list | `dingtalk-chat`(+ chat/ 5 个子章节) | ✅ 超集 |
|
||||
| calendar / contact / doc+doc/ / drive / mail / minutes / todo / wiki / aisearch / hrbrain / pat / markdown | 同名 `dingtalk-*` skill | ✅ 覆盖(doc 侧 mono 的 doc-file-ops/doc-list/doc-permission/doc-search 四篇已迁移为 `dingtalk-doc/references/doc.md:202-207` 的 drive/wiki 迁移表,属刻意重构) |
|
||||
| event.md(222 行) | `dingtalk-event`(event-im.md 399 行 + 完整订阅治理契约) | ✅ 超集 |
|
||||
| dev.md(212 行) | `dingtalk-dev`(12 篇 reference 共 584 行) | ✅ 超集(结构重组) |
|
||||
| oa / attendance / report / sheet / ding / devdoc / agoal | `dingtalk-misc` 对应 reference(sheet 多 3 篇:comment/formula/version) | ✅ 超集 |
|
||||
| simple.md(devdoc+oa 合集 + 意图判断 + 上下文传递表) | 已拆入 `dingtalk-misc/references/oa.md:309,425` 与 `devdoc.md:15,19` | ✅ 覆盖 |
|
||||
| 多组织/多账号(SKILL.md:64-70 一节) | `dingtalk-profile` 整个 skill | ✅ 超集 |
|
||||
| 意图决策树(SKILL.md:101-123) | `dws-shared/references/intent-guide.md`(536 行 ≥ mono 488 行)+ `routing.md` | ✅ 覆盖 |
|
||||
| best_practices 01–11 / lite / _common | 按产品分发 + `dws-shared/references/best_practices/_common/` | ✅ 覆盖 |
|
||||
| url-patterns / capability-limits / channel-login / error-codes | `dws-shared/references/` 同名 | ✅ 覆盖 |
|
||||
| field-rules.md(mono 全局位) | `dingtalk-aitable/references/field-rules.md` | ✅ 合理下沉(内容即 AI 表格字段规则) |
|
||||
|
||||
### 2.3 不对等项清单(mono 有、multi 无)
|
||||
|
||||
| # | 缺失项 | mono 锚点 | multi 现状证据 | 严重度 | 补齐方式(承载方) |
|
||||
|---|---|---|---|---|---|
|
||||
| M1 | **Recovery 闭环**(recovery-guide.md + global-reference §Recovery + 错误处理第 2 步) | `skills/mono/SKILL.md:296,311`、`references/recovery-guide.md`、`global-reference.md:62` | multi 全树 `RECOVERY_EVENT_ID` 零引用;`dws-shared/SKILL.md:83-89` 错误最短路径无 recovery;`dingtalk-dev/SKILL.md:124` 指向「root dws / dws-shared 的错误处理」→ **断链** | 🔴 | **dws-shared**:新增 `references/recovery-guide.md`(从 mono 移植)、SKILL.md 错误最短路径加 recovery 步、`global-reference.md` 补 Recovery 节(→ C1) |
|
||||
| M2 | **确认门禁协议 + 全局危险操作表** | `skills/mono/SKILL.md:137-187`(危险操作表 + 确认流程 + `confirmation_required` 识别与重试协议) | multi 仅 aitable/doc/misc 三个 SKILL.md 有产品级危险表;`confirmation_required` / 「确认门禁」全树零命中;`dws-shared/SKILL.md:37-38` 只有一行泛化规则 | 🔴 | **dws-shared**:全局确认门禁协议(识别 `confirmation_required`、原始命令追加 `--yes` 重试、`--dry-run` 预览、禁止管道喂答案)+ 危险操作索引指向各产品表(→ C2) |
|
||||
| M3 | **Schema 渐进查询教学** | `skills/mono/SKILL.md:198-292`(≈95 行:四层查询、`--compact`/`--all` 边界、字段速查、Schema/Help/业务数据边界表、漂移处理) | 各产品 SKILL.md 仅点状提及 leaf Schema(17 处);`dws-shared/references/global-reference.md:79-89`「命令自省」只有 `--help` | 🟡 | **dws-shared**:新增 Schema 渐进查询章节(改写为 multi 语境,链入渐进加载表)(→ C3) |
|
||||
| M4 | **scripts/report_inbox_today.py** | `skills/mono/scripts/report_inbox_today.py` | multi 无(misc 仅有 `report_received_today.py`);mono 文档也未引用它 | 🟢 | **dingtalk-misc**:先验证脚本仍可用 → 迁入 `scripts/` 并在 `report.md` 引用;不可用则连同 mono 侧一起删(→ C5) |
|
||||
| M5 | **LICENSE / NOTICE** | `skills/mono/LICENSE`、`NOTICE` | multi 20 个 skill 均无 | 🟡 | 每个 multi skill 根复制两份(或 release 打包期注入,`scripts/release/post-goreleaser.sh`)(→ C7) |
|
||||
| M6 | **aiapp 意图路由** | `skills/mono/SKILL.md:76,101`(产品表 + 决策树有 aiapp 行,但目标 `aiapp.md` 不存在 —— mono 自身死链) | multi 全树无 aiapp 路由/文档;仅 orphan 脚本 `dingtalk-misc/scripts/aiapp_create_and_poll.py` | 🟡 | 决策:**dws-shared/routing.md** + **dingtalk-misc** 产品索引补 aiapp 行并新建 reference,或明确下线该能力并清掉 mono 死链与 orphan 脚本(→ C5) |
|
||||
|
||||
### 2.4 multi 自身不一致项(不阻塞对等结论,但阻塞「multi 可独当一面」)
|
||||
|
||||
| # | 问题 | 证据 | 处理 |
|
||||
|---|---|---|---|
|
||||
| X1 | 16 个 orphan 脚本无任何文档引用(yida×13、finance×2、aiapp×1);`dws-shared/references/routing.md` 把「宜搭」路由到 dingtalk-misc,但 misc 产品索引无 yida 行、无 yida reference | `skills/multi/dingtalk-misc/scripts/`;`dingtalk-misc/SKILL.md:20-32` | 补文档(misc 产品索引 + reference)或移出发布包(→ C5) |
|
||||
| X2 | 死链:`dingtalk-chat/SKILL.md:131` 引用 `scripts/extract_media_id.py`,文件不存在(mono 也无) | 同上 | 补脚本或删引用(→ C4) |
|
||||
| X3 | 死链:`dws-shared/references/routing.md:22` 指向 `dingtalk-misc/references/markdown.md`,实际在 `dingtalk-markdown` | 同上 | 改指 `../../dingtalk-markdown/SKILL.md`(→ C4) |
|
||||
| X4 | `dingtalk-event/SKILL.md` 缺 `dws-shared` PREREQUISITE 与 `metadata:` 块(其余 19 个 skill 均有) | `skills/multi/dingtalk-event/SKILL.md:1-4` | 补齐(→ C4) |
|
||||
| X5 | 4 个 skill 仍是 🧪 EXPERIMENTAL + 「生产优先 mono」文案,与本工作区已翻转的默认矛盾(dingtalk-skill 已改,这 4 个漏改) | `dingtalk-profile/SKILL.md:15`、`dingtalk-hrbrain:15`、`dingtalk-markdown:15`、`dingtalk-pat:15` | 统一下调文案(→ C4,即 plan P2-4 剩余量) |
|
||||
| X6 | `<!-- SAFETY_PREAMBLE_INJECT -->` 标记存在于 5 个 SKILL.md,但仓库内无注入器 | `dingtalk-{pat,hrbrain,markdown,skill,profile}/SKILL.md` | 明确注入方(仓外流程则写注释)或移除标记(→ C4) |
|
||||
|
||||
### 2.5 测试与政策门覆盖
|
||||
|
||||
| 门面 | 覆盖 | 缺口 |
|
||||
|---|---|---|
|
||||
| `test/skill_tests.md` 覆盖表(40-54 行) | 13 产品 ≈256 用例 | 12/13 行引用 **mono 路径**(`references/products/...`),仅 dev 指 multi;`workbench` 行指向不存在的 `workbench.md`(54 行,mono/multi 均无);`devdoc` 行指 `simple.md`(47 行,multi 无此文件);未覆盖 doc/drive/mail/minutes/oa/sheet/wiki/aisearch/hrbrain/markdown/pat/profile/skill。multi 默认后需按 multi 路径重写并补产品(→ C6) |
|
||||
| `scripts/policy/check-skill-context-budget.sh` | 锁 `dingtalk-chat/SKILL.md` ≤14000B + shortcut 区块不膨胀 + mono SKILL.md 不含「充分阅读产品参考文件」回归(9-38 行);`gen_skill_shortcut_sections.py --check` 同时写 mono 与 multi 的 shortcut 区块 | mono 下线判据(plan §5-4)要求先替代对 `skills/mono/SKILL.md` 的依赖 |
|
||||
| `make skill-command-integrity`(`check-skill-commands.sh` → `test/skill_static/skill_static_test.go:119-133`) | 静态校验 mono+multi 两树文档中的命令与 flag | 覆盖 OK;X2/X3 类 reference 死链不在其校验面 |
|
||||
|
||||
**Part 2 结论**:multi **没有**覆盖 mono 的全部能力 —— 产品参考层面是超集,
|
||||
但全局能力缺 M1–M3 三块(recovery / 确认门禁 / Schema 教学),加 M4–M6 三个
|
||||
小项;另有 X1–X6 六项 multi 内部不一致。补齐全部落在 `dws-shared`(M1/M2/M3)、
|
||||
`dingtalk-misc`(M4/M6/X1)、各产品 skill(X2/X4/X5)与打包流程(M5)。
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — 优先级排序实施清单
|
||||
|
||||
> 估时以 1 名熟悉本仓库的工程师计(人日),口径同 plan §8。
|
||||
> 「状态」列:✅ 本工作区已完成|🚧 部分完成|⬜ 未做。
|
||||
|
||||
### 3.1 P0 — 先堵会丢数据/双份派发的洞(≈6.5–8.5d)
|
||||
|
||||
| ID | 状态 | 任务 | 文件级改动点 | 估时 | 依赖 |
|
||||
|---|---|---|---|---|---|
|
||||
| P0c-1 | ⬜ | agent home 清单下沉共享包,补 opencode 不对称 | 新 `internal/skillhome`(或 `internal/upgrade` 导出):合并 `paths.go:35-52` `knownSkillDirs` + `skill_setup.go:20-37` `skillSetupAgentHomes` + `skill_command.go:119-141` `agentSkillPaths`;setup/upgrade/测试共用 | 0.5d | — |
|
||||
| P0c-2 | ⬜ | 清单同步门禁 | 新 `scripts/policy/check-agent-homes-sync.sh`(进 `make policy`);install.sh:ps1:install.js:install-skills.sh 清单改可解析块 | 1d | P0c-1 |
|
||||
| P0b-1 | ❌ CANCELLED | state.json schema + 写入 | 2026-08-05:无运行时切换,不写 state.json | — | — |
|
||||
| P0b-2 | ❌ CANCELLED | 磁盘形态反推作 state 兜底 | 无 sticky / 无 state;upgrade 按包刷 multi | — | — |
|
||||
| P0-3 | ❌ CANCELLED | 备份式安装 + 失败整体回滚 | 2026-08-05:随切换产品线取消 | — | — |
|
||||
| P0a-1 | ✅ | upgrade 包驱动 multi | `UpgradeSkillLocations`:有 multi→始终 multi(含 mono 盘一次性迁移);legacy 无 multi→mono;不读 state | — | — |
|
||||
| P0a-2 | ✅ | force-multi 集成 / E2E | `paths_multi_test.go`(`MonoDiskMigratesToMulti` 等)+ `upgrade_skill_multi_e2e_test.go` | — | — |
|
||||
| P0-4 | ❌ CANCELLED | `x-dws-skill-mode` 请求头 | owner 决策移除 | — | — |
|
||||
|
||||
### 3.2 P1 — 生命周期命令 + 内容补全(≈7–7.5d,两条线可并行)
|
||||
|
||||
**管理命令线**
|
||||
|
||||
| ID | 状态 | 任务 | 文件级改动点 | 估时 | 依赖 |
|
||||
|---|---|---|---|---|---|
|
||||
| P1-1 | ❌ CANCELLED | `dws skill mode` status/set/rollback/--dry-run | 2026-08-05:无运行时模式切换产品 | — | — |
|
||||
| P1-3 | ⬜ | `dws skill remove`(新增,缺口 #5) | 按磁盘/约定前缀精确删内置 skill(**不**依赖已取消的 state.json);`--dry-run` 预览 | 1d | — |
|
||||
|
||||
**内容补全线**(对应 Part 2 编号)
|
||||
|
||||
| ID | 任务 | 文件级改动点 | 估时 | 依赖 |
|
||||
|---|---|---|---|---|
|
||||
| C1 | recovery 闭环进 multi(M1) | 新 `skills/multi/dws-shared/references/recovery-guide.md`;`dws-shared/SKILL.md:83-89` 错误最短路径加 recovery 步;`dws-shared/references/global-reference.md` 补 Recovery 节;`dingtalk-dev/SKILL.md:124` 断链改指 | 0.5d | — |
|
||||
| C2 | 确认门禁协议 + 危险操作索引(M2) | `dws-shared/SKILL.md` 增「确认门禁」节(移植 mono `SKILL.md:170-187` 协议)+ 危险操作索引表指向各产品 SKILL.md | 0.5d | — |
|
||||
| C3 | Schema 渐进查询教学(M3) | `dws-shared/SKILL.md` 渐进加载表加一行 + 新 `references/schema-usage.md`(改写 mono `SKILL.md:198-292`) | 0.5d | — |
|
||||
| C4 | multi 一致性修复(X2/X3/X4/X5/X6 = plan P2-4 剩余量) | `dws-shared/references/routing.md:22` 改指 dingtalk-markdown;`dingtalk-chat/SKILL.md:131` 死链处置;`dingtalk-event/SKILL.md` 补 PREREQUISITE+metadata;`dingtalk-{profile,hrbrain,markdown,pat}/SKILL.md:15` EXPERIMENTAL 文案下调;SAFETY_PREAMBLE_INJECT 标记处置 | 0.5d | — |
|
||||
| C5 | orphan 脚本与缺失产品处置(X1/M4/M6) | `dingtalk-misc/SKILL.md:20-32` 产品索引补 yida/finance/aiapp 行 + 新 reference(或把 16 个脚本移出发布包);`report_inbox_today.py` 验证后迁入或删除;aiapp 路由决策落 `dws-shared/references/routing.md` | 0.5–1d | — |
|
||||
| C6 | skill_tests.md multi 化 + 扩产品 | `test/skill_tests.md:40-54` 覆盖表改 multi 路径;修 workbench(54 行)/devdoc(47 行)死链;补 doc/drive/mail/minutes/oa/sheet/wiki 用例 | 1d | C1–C5(路径稳定后) |
|
||||
| C7 | LICENSE/NOTICE 进 multi(M5) | 20 个 `skills/multi/*/LICENSE|NOTICE`(或 `scripts/release/post-goreleaser.sh` 打包期注入) | 0.25d | — |
|
||||
|
||||
### 3.3 P2 / W4 — 收尾(≈1d)
|
||||
|
||||
| ID | 状态 | 任务 | 估时 | 依赖 |
|
||||
|---|---|---|---|---|
|
||||
| P1-2 | ⬜ | beta 轨默认切 multi(版本门控;本工作区已全量切,可选择保留全量或回退为 beta 先行) | 0.5–1d | P1-1 |
|
||||
| P2-1 ~ P2-3 | ✅ | setup / install.sh / install.ps1 / install-skills.sh / install.js 默认 multi | — | 已完成 |
|
||||
| P2-4 | 🚧 | 文案翻转:README×2、dingtalk-skill 已改 ✅;4 个 EXPERIMENTAL 漏改 → 并入 C4 | — | — |
|
||||
| W4 | ⬜ | mono deprecation 警告(不删代码)+ mono 下线判据第 4 条(替代 `check-skill-context-budget.sh:10,34-38` 对 mono 的依赖) | 0.5d | P2、C6 |
|
||||
|
||||
### 3.4 依赖图与排期建议
|
||||
|
||||
```text
|
||||
P0c-1 ─► P0c-2
|
||||
P0b-1 ─► P0b-2 ─┐
|
||||
P0-3 ───────────┼─► P0a-1 ─► P0a-2 ─► P1-1 ─► P1-3
|
||||
P0b-1 ──────────┴─► P0-4
|
||||
C1…C5(互相独立,可与 P0 并行)─► C6
|
||||
```
|
||||
|
||||
- **W1**:P0c-1 → P0b-1/P0b-2 → P0-3 → P0a-1/P0a-2(管理面止血);并行 C1–C4(内容高危项)。
|
||||
- **W2**:P0-4、P1-1、P1-3;并行 C5、C7。
|
||||
- **W3**:C6(内容面收口)+ P1-2 beta 决策;`make policy` 全绿。
|
||||
- **W4**:mono deprecation + 观察(plan §4 原节奏不变)。
|
||||
|
||||
合计:P0 ≈ 6.5–8.5d + P1 管理 ≈ 3–3.5d + 内容 C1–C7 ≈ 3.5–4d + P2/W4 ≈ 1d
|
||||
= **14–17 人日**。砍法同 plan §8.4:内容线可砍 C3/C7(教学与法律文件可后置),
|
||||
管理线不可砍 P0b/P0-3/P0a(缺了就是现在这副「能装不能管」的样子)。
|
||||
@@ -0,0 +1,146 @@
|
||||
# Skill 分发与消费机制调研(含 lark-cli 对标)
|
||||
|
||||
> 配套方案:[skill-multi-migration-plan.md](skill-multi-migration-plan.md)
|
||||
> 调研日期:2026-08-04,基于 `feat/skill-mode-migration` 工作区代码级梳理 +
|
||||
> lark-cli(larksuite/cli@main)公开仓库调研。
|
||||
|
||||
## 1. DWS 分发链路(源树 → 制品 → 渠道)
|
||||
|
||||
```
|
||||
skills/mono/ ─┬─ go build ──► embed.FS(skills_embed.go:28)
|
||||
skills/multi/ ┘ │ dws skill setup 默认源
|
||||
│
|
||||
└─ post-goreleaser.sh:220-248 ──► dws-skills.zip
|
||||
布局:zip 根 = mono 副本(向后兼容)+ mono/ + multi/
|
||||
│
|
||||
┌───────┬───────┼────────┬─────────┬──────────┐
|
||||
GitHub Gitee OSS npm Homebrew 专项脚本
|
||||
Release (镜像) (只发 tarball (cellar install-event/
|
||||
│ │ 不读) │ 不铺agent) install-devapp
|
||||
│ │ │
|
||||
install.sh/ps1 postinstall
|
||||
install-skills.sh install.js
|
||||
│ │
|
||||
├─► 各 agent home ◄────┤ 永远 mono:<agent>/dws/
|
||||
└─► ~/.dws/skills/{mono,multi} 缓存
|
||||
```
|
||||
|
||||
渠道清单(7 个安装/分发面):
|
||||
|
||||
| 面 | skill 行为 | mode 概念 |
|
||||
|---|---|---|
|
||||
| `scripts/install.sh` | 装 mono 到 agent homes + 双缓存;选 multi **只打印提示、连缓存都跳过** | 有(半残) |
|
||||
| `scripts/install.ps1` | 同 install.sh(Windows) | 有(半残) |
|
||||
| `scripts/install-skills.sh` | 只装 mono,缓存 mono+multi | 无 |
|
||||
| `build/npm/install.js` | 永远 mono;缓存 mono+multi | 无 |
|
||||
| Homebrew formula | 整包 zip 进 cellar,不铺 agent、不写缓存,caveats 提示手动 setup | 无 |
|
||||
| `scripts/install-event.sh` | 装 mono + 缓存 `multi/dingtalk-event`;多 `.config/opencode/skills` | 无 |
|
||||
| `scripts/install-devapp.sh` | 缓存 `multi/dingtalk-dev` | 无 |
|
||||
|
||||
产物事实:`dws-skills.zip` 已含 mono/multi 双树,**切 multi 不需要改 release 产物**。
|
||||
|
||||
## 2. DWS 消费链路(源 → agent 目录)
|
||||
|
||||
### 2.1 `dws skill setup`(`internal/app/skill_setup.go`)
|
||||
|
||||
- 源优先级:`--source` / `DWS_SKILL_SOURCE`(失败不回退)→ **embed 默认**
|
||||
(`skill_setup_embed.go:50-58`);legacy 候选(exe 旁/cwd/`~/.dws/skills`)
|
||||
仅在绕过 wrapper 直连时才走。
|
||||
- 目标:`skillSetupAgentHomes` 16 个 agent home,父目录门控(i=0 `.agents`
|
||||
无条件,其余需 `~/.claude` 这类父目录存在)。`--target all` **不含
|
||||
opencode**,但 `agentSkillPaths` 命名 target 含(不对称,测试只锁单向)。
|
||||
- 布局:mono → `<agent-home>/dws/`;multi → `<agent-home>/` 平铺兄弟目录。
|
||||
- 互斥清理:装 mono 删 `dingtalk-*`、装 multi 删 `dws/`;**best-effort,
|
||||
失败仅 warning 继续装**(`cleanupMutualExclusion:611-620`),无备份。
|
||||
- multi 过滤:`-s/--skill` 与 `-x/--exclude` 互斥;`dws-shared` 强制包含;
|
||||
未点名的已有 `dingtalk-*` 保留(additive)。
|
||||
|
||||
### 2.2 `dws upgrade`(`internal/upgrade/paths.go`)
|
||||
|
||||
- `LocateSkillMD` 命中 zip 根 mono → `UpgradeSkillLocations` 只写
|
||||
`<agent>/dws/`:**不识 multi、不清理 `dingtalk-*`、不更新 `~/.dws/skills`**。
|
||||
- 后果:multi 用户升级后 **mono + multi 共存**,Agent 双份派发。
|
||||
- `--rollback` 只回滚二进制,不回滚 skill。
|
||||
|
||||
### 2.3 市场 skill(`dws skill install`)
|
||||
|
||||
解压到 agent skills **根目录**(非 `dws/` 子目录),无 mode 概念;互斥清理按
|
||||
`dingtalk-*` 前缀扫描,依赖"市场无同名前缀"隐性约定。
|
||||
|
||||
### 2.4 状态与缓存
|
||||
|
||||
- **无任何已安装模式状态**:`~/.dws/` 有 auth/backups/cache,无 install
|
||||
manifest;判断 mode 只能看磁盘形态。
|
||||
- `~/.dws/skills` 缓存事实**只写不读**(默认 setup 走 embed),upgrade 不更新,
|
||||
版本漂移不可见。
|
||||
|
||||
## 3. lark-cli 分发机制(larksuite/cli@main)
|
||||
|
||||
npm 包 `@larksuite/cli` 是薄壳(`files` 仅 install.js / install-wizard.js /
|
||||
run.js / checksums.txt):
|
||||
|
||||
- **二进制**:postinstall 按平台从 GitHub Releases 下载,SHA256 校验
|
||||
(checksums.txt 随 npm 包发);镜像链 GitHub → 用户 registry 派生镜像 →
|
||||
npmmirror 兜底;host allowlist + checksum 双保险;`run.js` 缺二进制自动补下,
|
||||
Windows 有 `.old` 崩溃恢复。
|
||||
- **Skills**:不进 npm 包、不进二进制。repo 根 `skills/` 即事实源,由生态
|
||||
安装器 `npx skills add larksuite/cli -y -g`(vercel-labs/skills)安装;
|
||||
wizard 首选 `https://open.feishu.cn` 直链,GitHub shorthand 兜底。
|
||||
- **一键向导** `npx @larksuite/cli@latest install`:run.js 拦截 `install` →
|
||||
install-wizard.js 串联 4 步(npm 全局装/升级 → skills → config init →
|
||||
auth login),每步幂等(`skills ls -g` 检测 `lark-*` 已装则跳过);
|
||||
非 TTY 降级为"装完打印后续命令"。
|
||||
|
||||
生态安装器 `skills` CLI 提供的能力:
|
||||
|
||||
- 76 个 agent 目录清单生态维护,自动探测;project/global 双 scope;
|
||||
- **symlink canonical(推荐)或 copy**;symlink 下升级 = 更新一处;
|
||||
- `list / find / update / remove` 全生命周期;**skill 升级由
|
||||
`npx skills update` 承担,lark-cli 自己不写 skill 刷新逻辑**;
|
||||
- 发现约定兼容 catalog 布局 `skills/<catalog>/<name>/SKILL.md`。
|
||||
|
||||
## 4. 对标:DWS vs lark-cli
|
||||
|
||||
| 维度 | lark-cli | DWS 现状 |
|
||||
|---|---|---|
|
||||
| 分发单元 | repo `skills/`(源码即事实源) | zip + embed + 5 处拷贝缓存 |
|
||||
| 安装器 | 1 个(生态工具) | 7 个自维护脚本,已漂移 |
|
||||
| 落盘 | symlink 单点更新 | 全量 copy,升级重写 16 home |
|
||||
| skill 升级 | `npx skills update`(生态承担) | `dws upgrade` 自写,不识 multi |
|
||||
| mono/multi | 不存在此问题(天生多 skill) | 互斥/切换/状态全自建 |
|
||||
| npm 包 | 薄壳运行时下载 | 全平台 archive + zip 全打进 tarball |
|
||||
| 大陆镜像 | registry 派生 + npmmirror | Gitee fallback + OSS 只发不读 |
|
||||
|
||||
## 5. "分发外包给生态,自己只维护源码目录"是什么
|
||||
|
||||
职责切分:skill 的分发/安装/升级/卸载交给生态标准化工具(`npx skills`,
|
||||
"agent skill 界的 npm"),DWS 只保证 repo 里 `skills/` 符合 agentskills.io
|
||||
规范。它消掉的正是 DWS 现在自维护的四块问题:
|
||||
|
||||
1. **N 份事实源**(zip 三拷贝 + embed + 缓存 + 16 home)→ 只剩 repo 目录一份;
|
||||
2. **7 个自写安装器** → 零个,agent 清单别人维护(新 agent 自动支持);
|
||||
3. **自建升级/切换/回滚**(UpgradeSkillLocations、互斥清理、方案中的
|
||||
state.json/备份/`dws skill mode`)→ symlink 模式更新 canonical 一处;
|
||||
4. **lark-cli 因此根本没有 mono/multi 之争**,也没有 P0 要修的那些 bug。
|
||||
|
||||
### 代价与前提(不是免费午餐)
|
||||
|
||||
| 风险 | 说明 | 对策 |
|
||||
|---|---|---|
|
||||
| 版本错配 | DWS skill 从 Cobra 树生成,与 CLI 版本强耦合;embed 天然同版,生态安装从主分支拉可能错配。**已实测**:`skills add`(v1.5.21)无 tag/ref 版本固定参数,`@` 后接的是 skill 名而非 git ref;唯一的可复现机制是 project 级 `skills-lock.json`(experimental_install) | 短期:发布说明引导"skill 随 CLI 升级(`skills update`)";中期:release 时推一个 `release/vX.Y.Z` 镜像分支或专用 skills 镜像仓供按版本安装;或接受错配(skill 内容为文档,错配成本=提到不存在的命令) |
|
||||
| 网络可达 | 依赖 npx + GitHub;大陆/内网现靠 Gitee fallback | `skills` CLI 支持任意 git URL,Gitee 镜像仓兜底,链路需验证 |
|
||||
| 离线/打包场景 | 悟空 bundled-skills、企业预装镜像生态通道覆盖不了(2026-08-05 注:悟空分发线已于当日下线,「悟空 bundled-skills」一项不再适用;企业预装镜像约束仍在) | 自维护打包保留一条 |
|
||||
| 生态工具策略漂移 | 清单/发现约定/默认值被动跟随 | 作为增量通道而非唯一通道,保留 embed 兜底 |
|
||||
|
||||
## 6. 结论
|
||||
|
||||
- **生态分发通道已否决**(2026-08-04):无版本固定(`skills add` 不支持
|
||||
tag/ref,实测 v1.5.21)、依赖 Node + GitHub 可达、mono 会被一起发现、
|
||||
悟空/离线场景覆盖不了。分发维持全自维护。(2026-08-05 注:悟空分发线
|
||||
已于当日下线,「悟空场景覆盖不了」一条随之失效;其余否决理由与结论
|
||||
不变。)
|
||||
- 一个月内:按方案 P0–P2 修自维护通道并切 multi 默认(zip + embed +
|
||||
安装脚本,产物布局不变)。
|
||||
- 终态即"自维护通道修好之后"的形态,不再向 lark-cli 的生态外包形态收敛。
|
||||
- 调研保留备查:`npx -y skills add . --list` 实测可发现全部 21 个 skill
|
||||
(若未来生态工具补齐版本固定能力,可重新评估本决策)。
|
||||
@@ -0,0 +1,307 @@
|
||||
# Skill 多 skill(multi)切换方案
|
||||
|
||||
> 目标:一个月内把 DWS 的 Agent skill 默认安装形态从 mono(单 skill)切换为
|
||||
> multi(按产品拆分),两套并行期后下掉 mono。
|
||||
> 本文基于对分发/消费链路的代码级梳理(2026-08-04),所有锚点均可跳转验证。
|
||||
> 机制调研与 lark-cli 对标细节:[skill-distribution-mechanism.md](skill-distribution-mechanism.md)
|
||||
|
||||
## 1. 目标与约束
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 终态 | 新装/升级默认铺 multi(`<agent-home>/dingtalk-*/`、`dws-shared/`);mono 仅 opt-in;最终物理删除 mono |
|
||||
| 并行期 | 约一个月,mono 保留可切换、可回退 |
|
||||
| 硬约束 | DWS 无服务端灰度/远程配置;skill 是本地文件分发;安装面至少 7 个且已有漂移 |
|
||||
| 前置原则 | 先修已存在的 upgrade×multi 双份 bug,再谈切默认 |
|
||||
|
||||
## 2. 现状关键事实(梳理结论)
|
||||
|
||||
分发侧:
|
||||
|
||||
- `dws-skills.zip` 布局:zip 根 = mono 副本(向后兼容)+ `mono/` + `multi/`
|
||||
(`scripts/release/post-goreleaser.sh:220-248`)。**产物无需改动**。
|
||||
- 二进制 `//go:embed all:skills/mono all:skills/multi`(`skills_embed.go:28`),
|
||||
`dws skill setup` 默认源就是 embed,**与分发渠道无关**。
|
||||
- 7 个安装/分发面:install.sh、install.ps1、install-skills.sh、npm install.js、
|
||||
Homebrew formula、install-event.sh、install-devapp.sh。
|
||||
- OSS 只发不读;大陆链路靠 Gitee fallback。
|
||||
|
||||
消费侧:
|
||||
|
||||
- `dws skill setup`:源优先级 `--source`/env → embed;目标 16 个 agent home
|
||||
(父目录门控);互斥清理 best-effort、失败仅 warning、无备份无回滚
|
||||
(`internal/app/skill_setup.go`)。
|
||||
- `dws upgrade`:`LocateSkillMD` 命中 zip 根 mono → `UpgradeSkillLocations`
|
||||
只写 `<agent>/dws/`、不清理 `dingtalk-*`、不更新 `~/.dws/skills`
|
||||
(`internal/upgrade/paths.go:119-172`)。**multi 用户升级后 mono+multi 共存**。
|
||||
- `dws upgrade --rollback` 只回滚二进制,不回滚 skill。
|
||||
- **无任何已安装模式状态**:`~/.dws/` 无 install manifest。
|
||||
- `~/.dws/skills` 缓存事实上只写不读(默认 setup 走 embed),且 upgrade 不更新,
|
||||
版本漂移不可见。
|
||||
- `skill setup --target all` 不含 opencode,但 `agentSkillPaths` 含
|
||||
(`skill_command.go:119-141` vs `skill_setup.go:20-37`)。
|
||||
- 市场 skill(`dws skill install`)解压到 agent skills 根,与内置 skill 无 mode
|
||||
概念;互斥清理按 `dingtalk-*` 前缀扫描,依赖"市场无同名前缀"这一隐性约定。
|
||||
|
||||
## 3. 总体设计
|
||||
|
||||
### 3.1 单一事实源收敛(P0c)
|
||||
|
||||
agent home 清单目前在 5+ 处各写一份(注释约定 keep in sync,无门禁)。
|
||||
|
||||
- Go 侧:`skillSetupAgentHomes` 与 `knownSkillDirs` 合并为一个导出列表
|
||||
(放在 `internal/upgrade` 或新 `internal/skillhome` 包),补 opencode,
|
||||
setup/upgrade/测试共用。
|
||||
- 脚本侧:install.sh / install.ps1 / install.js / install-skills.sh /
|
||||
install-event.sh / install-devapp.sh 的清单由同一个 JSON 生成或政策脚本
|
||||
比对(新增 `scripts/policy/check-agent-homes-sync.sh`,进 `make policy`)。
|
||||
|
||||
### 3.2 安装状态文件(P0b)— ❌ CANCELLED(2026-08-05)
|
||||
|
||||
原计划新增 `~/.dws/skills/state.json`(mode / cli_version / installed /
|
||||
previous / backup)。**已取消**:owner 决策不做运行时模式切换产品;upgrade
|
||||
有 multi 包时直接刷 multi,不再需要状态文件作为正确性或切换前提。
|
||||
|
||||
### 3.3 备份式安装与真回滚(P0a)— ❌ CANCELLED(2026-08-05)
|
||||
|
||||
原计划把 `RemoveAll` 改为 `mv` 到 `~/.dws/skills/backup/<ts>-<mode>/` 并
|
||||
支持失败整体回滚 / `dws skill mode rollback`。**已取消**:无运行时切换则
|
||||
不交付备份回滚产品面。安装/清理仍可为直接删除;mono retirement 版本若做
|
||||
一次性迁移,届时再单独评估临时备份,不预建 switch UX。
|
||||
|
||||
### 3.4 upgrade 包驱动 multi 刷新(P0a,已落地)
|
||||
|
||||
> 取代「按 state.json mode 刷新」与「磁盘粘性」:有 multi 包时一次性刷成 multi。
|
||||
|
||||
- `UpgradeSkillLocations(extractedDir)`:**不**推断磁盘布局。
|
||||
- 包内有 multi 技能树 → 始终 multi 刷新(清 `dws/`、刷产品 skill、刷
|
||||
`~/.dws/skills/multi`);存量 mono 在日常 upgrade 上一并迁走。
|
||||
- 无 multi 树的 legacy 包 → mono 刷新回退。
|
||||
- 这是包驱动的一次性迁移,**不是**运行时 mode-switch 产品。
|
||||
- 互斥清理在 multi 路径内执行,避免双布局长期共存。
|
||||
|
||||
### 3.5 模式切换命令(P1)— ❌ CANCELLED(2026-08-05)
|
||||
|
||||
原计划 `dws skill mode status|set|rollback|--dry-run`。**已取消**。
|
||||
|
||||
用户若需改布局:重新走安装入口(`dws skill setup --mode <mono|multi> --yes`
|
||||
或安装脚本 `DWS_SKILL_MODE=`),不是 lifecycle 切换命令。
|
||||
|
||||
### 3.6 默认切换(P2)
|
||||
|
||||
改默认值="multi 默认、mono opt-in",七个面一起改 + 门禁锁一致性:
|
||||
|
||||
| 面 | 改动 |
|
||||
|---|---|
|
||||
| `dws skill setup` | 无 `--mode` 时默认 multi(交互选项顺序反转,mono 标 legacy) |
|
||||
| install.sh / install.ps1 | multi 分支**真正安装**(现在是打印提示跳过);`DWS_SKILL_MODE=mono` opt-in;TTY 默认项改 multi |
|
||||
| install-skills.sh / npm install.js | 从零加 mode 支持(env `DWS_SKILL_MODE` / `--skill-mode`),默认 multi |
|
||||
| Homebrew formula | caveats 改提示 `dws skill setup`(默认即 multi),无需改资源 |
|
||||
| install-event.sh / install-devapp.sh | 维持单 skill 语义,但改走共享 install 函数 |
|
||||
|
||||
文档同步:README/README_zh(`README_zh.md:81` "默认 1")、
|
||||
`skills/multi/dingtalk-skill/SKILL.md` 的 🧪 EXPERIMENTAL 措辞下调、
|
||||
install.sh 内 multi 警告文案、AGENTS.md"生产优先 mono"表述。
|
||||
|
||||
### 3.7 灰度与止血(无服务端能力下的替代)
|
||||
|
||||
- **L1 渠道灰度(先行)**:beta 轨(GitHub prerelease / npm `beta` dist-tag /
|
||||
`dws upgrade --beta`)先切默认 multi,stable 保持 mono。零新增代码。
|
||||
- **L2 确定性分桶(可选增强)**:随 release 发 `rollout.json`
|
||||
(GitHub asset + OSS 同步),安装/升级时 `hash(machine-id) % 100 < pct` 决策
|
||||
并粘入 `state.rollout`。规则:本地显式 env/flag 永远优先;拉取失败 fail-safe
|
||||
mono(并行期)/ 保持现状(切默认后);存量机器不被 rollout 改模式。
|
||||
- **Kill switch**:`rollout.json` pct=0(若上 L2,已砍)+ beta 撤回 +
|
||||
公告引导重装 `dws skill setup --mode mono --yes`(**无** `skill mode`
|
||||
命令;备份回滚产品已随 D5 取消)。
|
||||
- **可观测**:原 `x-dws-skill-mode` 请求头方案 **CANCELLED**(2026-08-05
|
||||
owner);灰度与下线判断改 issue 反馈 + 主动回访。
|
||||
|
||||
### 3.8 安装与升级的语义矩阵(两条路径都要支持 multi)
|
||||
|
||||
| 场景 | 时期 | 行为 |
|
||||
|---|---|---|
|
||||
| 新装 | 并行期(切默认前) | mono 默认,`DWS_SKILL_MODE=multi` / 交互可选 multi |
|
||||
| 新装 | 切默认后 | **multi 默认**,`DWS_SKILL_MODE=mono` / `--mode mono` / TTY opt-in(**仅安装时**) |
|
||||
| 升级(存量 mono) | 含 `multi/` 的包 | **一次性刷成 multi**(清 `dws/`);无 switch 提示命令 |
|
||||
| 升级(存量 multi) | 含 `multi/` 的包 | multi 刷新,清过期 skill,刷 multi 缓存 |
|
||||
| 升级(无安装) | 含 `multi/` 的包 | 安装 multi(与安装默认一致) |
|
||||
| 升级(任意磁盘) | legacy 无 multi 树的包 | mono 刷新回退 |
|
||||
| `dws upgrade --rollback` | 任意 | 只回滚二进制(现状);skill 无独立 rollback 命令 |
|
||||
|
||||
原则:**升级不做磁盘粘性**;有 multi 包时一律刷 multi(含存量 mono 一次性
|
||||
迁移)。默认翻转与安装 opt-in 仍只影响新装/重装。无运行时 `dws skill mode`
|
||||
产品。
|
||||
|
||||
## 4. 阶段与时间线(4 周)
|
||||
|
||||
| 周 | 内容 | 出口标准 |
|
||||
|---|---|---|
|
||||
| W1 | P0a upgrade mode-aware + P0b state.json + P0c 清单收敛与门禁 + 备份式安装 | upgrade×multi 集成测试(装 multi → upgrade → 无 `dws/` 残留);备份回滚测试(模拟中途失败);`make policy` 含 homes 同步检查 |
|
||||
| W2 | P1 `dws skill mode`(status/set/rollback/dry-run);beta 轨默认切 multi(L1);`x-dws-skill-mode` 头 | 双向切换 + 中断恢复手工验收;beta 轨冒烟 |
|
||||
| W3 | P2 stable 默认切 multi、mono 降为 opt-in;文案翻转;(可选 L2 分桶 5%→20%) | 七面默认行为一致(政策脚本);issue/请求头占比观察 |
|
||||
| W4 | (L2 则 50%→100%);mono 打 deprecation 警告,**不删代码** | 连续 7 天无 multi 相关 P1 |
|
||||
|
||||
## 5. mono 下线判据(不满足则不删)
|
||||
|
||||
> 判据更新(2026-08-05):原判据 3「悟空 bundled skill 分发线(dws_res →
|
||||
> bundled-skills,本仓库外)已切 multi」作废——悟空分发线已于当日决策
|
||||
> 下线(见 [skill-multi-roadmap.md](skill-multi-roadmap.md)
|
||||
> 「悟空线下线的影响(2026-08-05)」),无仓外 mono 依赖,mono 下线不再
|
||||
> 有仓外节奏闸门。判据重新编号如下(原 4/5 顺延为 3/4)。
|
||||
|
||||
1. `x-dws-skill-mode=multi` 请求占比 ≥ 90%;
|
||||
2. mono 主动 opt-in 率 ≤ 2%(install 脚本/命令埋点);
|
||||
3. 连续两周无 multi P1;
|
||||
4. 已有等价政策门替代 `scripts/policy/check-skill-context-budget.sh` 对
|
||||
`skills/mono/SKILL.md` 的依赖;`skills_embed.go` 去掉 `all:skills/mono`;
|
||||
存量 `<agent>/dws` 目录有"遇到即迁移清理"逻辑。
|
||||
|
||||
满足后单独一个版本窗口物理删除 `skills/mono/`,install/setup/upgrade 中 mono
|
||||
分支改为报错并指向 `dws skill mode set multi`。
|
||||
|
||||
## 6. 风险与对策
|
||||
|
||||
| 风险 | 对策 |
|
||||
|---|---|
|
||||
| 半装状态(清理成功、拷贝失败) | 3.3 备份式安装,失败整体回滚 |
|
||||
| multi 用户被 upgrade 塞回 mono | 3.4 mode-aware,P0a 先修 |
|
||||
| Agent 目录清单继续漂移 | 3.1 单一事实源 + policy 门禁 |
|
||||
| 互斥清理误伤市场 `dingtalk-*` skill | 清理前校验目录内 SKILL.md frontmatter 属 DWS 产品集,写成测试 |
|
||||
| 无灰度全切翻车 | beta 轨先行 + 备份回滚 + kill switch 公告命令 |
|
||||
| 用户不重启 AI 工具读到旧 skill | mode set / setup / install 输出统一提示重启 |
|
||||
| `~/.dws/skills` 缓存与 embed 版本漂移 | upgrade 时同步刷新对应 mode 缓存;长期可评估废弃缓存 |
|
||||
|
||||
## 7. 分发机制对标 lark-cli(2026-08-04 调研)
|
||||
|
||||
### 7.1 lark-cli 的实际分发结构
|
||||
|
||||
npm 包 `@larksuite/cli` 是一个**薄壳**(package.json `files` 只有
|
||||
`install.js` / `install-wizard.js` / `run.js` / `checksums.txt`):
|
||||
|
||||
- **二进制**:postinstall 时按平台从 GitHub Releases 下载,SHA256 校验
|
||||
(checksums.txt 随 npm 包发布);镜像链 = GitHub → 用户 registry 派生镜像 →
|
||||
npmmirror 兜底,host allowlist + checksum 双保险。`run.js` 在二进制缺失时
|
||||
自动补下载;Windows 有 `.old` 崩溃恢复。
|
||||
- **Skills**:**不进 npm 包、不进二进制**。repo 根 `skills/` 目录即事实源,
|
||||
由生态通用安装器 `npx skills add larksuite/cli -y -g`(vercel-labs/skills)
|
||||
安装;wizard 首选 `https://open.feishu.cn` 直链、GitHub shorthand 兜底。
|
||||
- **一键向导** `npx @larksuite/cli@latest install`:run.js 拦截 `install`
|
||||
子命令 → install-wizard.js 串联 4 步(npm 全局装/升级 → skills 安装 →
|
||||
config init → auth login),每步幂等可跳过(`skills ls -g` 检测 `lark-*`
|
||||
已装则跳过);非 TTY 自动降级为"装完打印后续命令"。
|
||||
|
||||
生态安装器 `skills` CLI 的能力(lark-cli 免费获得的):
|
||||
|
||||
- **76 个 agent 的目录清单由生态维护**,自动探测已装 agent;project/global
|
||||
两种 scope;
|
||||
- **symlink 到 canonical 副本(推荐)或 copy** —— symlink 模式下升级
|
||||
= 更新 canonical 一份,所有 agent 即时生效;
|
||||
- `list / find / update / remove` 全套生命周期命令,skill 升级由
|
||||
`npx skills update` 承担,**lark-cli 自己不写 skill 刷新逻辑**;
|
||||
- 发现约定兼容 catalog 布局 `skills/<catalog>/<name>/SKILL.md`。
|
||||
|
||||
### 7.2 与 DWS 的关键差异
|
||||
|
||||
| 维度 | lark-cli | DWS 现状 |
|
||||
|---|---|---|
|
||||
| 分发单元 | repo `skills/` 目录(源码即事实源) | zip + 二进制 embed + 5 处拷贝缓存 |
|
||||
| 安装器数量 | **1 个**(生态工具,76 agent 清单别人维护) | **7 个**自维护脚本,清单已漂移 |
|
||||
| 落盘方式 | symlink(canonical 单点更新) | 全量 copy,升级要重写 16 个 home |
|
||||
| skill 升级 | `npx skills update`(生态承担) | `dws upgrade` 自写,且不识 multi |
|
||||
| mono/multi 问题 | **不存在** —— 天生按目录多 skill | 互斥清理/模式切换/状态全是自建 |
|
||||
| npm 包体积 | 薄壳(运行时下载单平台二进制) | 全平台 archive + skills.zip 全打进 tarball |
|
||||
| 大陆镜像 | registry 派生 + npmmirror | Gitee fallback + OSS(只发不读) |
|
||||
|
||||
### 7.3 结论:生态分发通道**已否决**(2026-08-04)
|
||||
|
||||
~~借生态安装器做分发通道~~ 方向经评估后**放弃**。否决原因(均为实测):
|
||||
|
||||
1. **无版本固定**:`skills add`(v1.5.21)不支持 tag/ref 安装,而 DWS skill
|
||||
从 Cobra 树生成、与 CLI 版本强耦合,错配不可接受;唯一可复现机制
|
||||
`skills-lock.json` 仍是 experimental。
|
||||
2. **依赖 Node + GitHub 可达**:curl|sh / Homebrew / 大陆 Gitee fallback 的
|
||||
用户环境大量无 Node,生态通道在这些场景是断的。
|
||||
3. **mono 会被一起发现**:发布即制造双份,须等 mono 下线才能发布,节奏不合。
|
||||
4. **悟空 bundled / 离线预装**生态通道永远覆盖不了。(2026-08-05 注:
|
||||
悟空分发线已下线,此条约束随之消失;前 3 条否决理由仍成立。)
|
||||
|
||||
**决策:分发维持全自维护通道**(zip + embed + 安装脚本),按 P0–P2 落地;
|
||||
不从 lark-cli 借鉴分发架构。可保留的借鉴点只剩两个本地语义,与生态无关:
|
||||
|
||||
1. **wizard 式幂等安装**:`install.sh` 的 multi 分支从"打印提示跳过"改为
|
||||
检测 state 可重入的真正安装(参考 install-wizard 的步骤化幂等)。
|
||||
2. **npm 下载校验**:install.js 如后续薄壳化,可参考其 host allowlist +
|
||||
checksum 双保险与镜像链(GitHub → registry 派生 → npmmirror)设计。
|
||||
与本次 multi 迁移解耦,不单列任务。
|
||||
|
||||
## 8. 工作量评估与任务拆解
|
||||
|
||||
> 以 1 名熟悉本仓库的工程师计(人日)。规模基线:涉及生产代码约 5.0k 行
|
||||
> (Go 3.0k + shell/ps1/js 2.0k),已有测试 1.2k 行可复用。
|
||||
|
||||
### 8.1 总体判断
|
||||
|
||||
**核心路径 13–17 人日,一人一个月可行但偏紧**。风险不在 Go 而在"shell /
|
||||
ps1 / js 三语言 × 七面同步"和对应的测试矩阵。可选项(rollout 分桶、npm
|
||||
薄壳化、symlink、生态通道发布)全部可砍可后置,砍后**最小可行集 8–10
|
||||
人日**(见 8.4)。
|
||||
|
||||
### 8.2 任务拆解(带依赖)
|
||||
|
||||
```
|
||||
P0c 清单收敛 ──┐
|
||||
P0b state.json ─┼─► P0a upgrade mode-aware ─► P1 skill mode ─► P2 切默认
|
||||
备份式安装 ────┘ │ │
|
||||
└─► x-dws-skill-mode 头 └─► beta 轨先切(P2 预演)
|
||||
```
|
||||
|
||||
| # | 任务 | 改动面 | 估时 | 依赖 |
|
||||
|---|---|---|---|---|
|
||||
| P0c-1 | agent home 清单下沉共享包(setup/upgrade 共用),补 opencode | `internal/upgrade/paths.go` 或新 `internal/skillhome`(~80 行新代码) | 0.5d | — |
|
||||
| P0c-2 | `scripts/policy/check-agent-homes-sync.sh`:比对 sh/ps1/js/sh 专项清单与 Go 清单,进 `make policy` | 新脚本 ~120 行 + 各脚本清单改成可解析块 | 1d | P0c-1 |
|
||||
| P0b-1 | state.json schema + setup 写入(含 installed 列表、agent_homes、previous) | `internal/app/skill_setup.go` +新文件 ~200 行 | 1d | — |
|
||||
| P0b-2 | 磁盘形态反推(dws/ → mono;dingtalk-* → multi;都有 → drift 报错)+ 单测 | ~120 行 | 0.5–1d | P0b-1 schema |
|
||||
| P0-3 | 备份式安装:RemoveAll→mv backup、失败自动回滚、保留最近 2 份、改 warning 语义 | setup install 两函数重写 ~150 行 + 测试 | 1.5–2d | — |
|
||||
| P0a-1 | `UpgradeSkillLocations(dir, mode)`:mono 现状+清残留;multi 从 zip `multi/` 平铺刷新+清 `dws/`+刷缓存 | `internal/upgrade/paths.go` ~150 行 | 1.5d | P0c-1、P0b |
|
||||
| P0a-2 | upgrade×multi 集成测试(装 multi→upgrade→无 dws/ 残留、dingtalk-* 已刷新) | 测试 ~200 行 | 1d | P0a-1 |
|
||||
| P0-4 | `x-dws-skill-mode` 请求头(仿 `x-dws-channel`) | `internal/auth/oauth_helpers.go` 附近 ~30 行 | 0.5d | P0b |
|
||||
| P1-1 | `dws skill mode` status/set/rollback/--dry-run | 新文件 ~350 行 + 测试 | 2–2.5d | P0b、P0-3 |
|
||||
| P1-2 | beta 轨默认切 multi(版本门控的默认值翻转,stable 不变) | setup/install.sh 默认值逻辑 ~40 行 | 0.5–1d | P1-1 |
|
||||
| P2-1 | setup 默认翻转 + 交互选项反转 + mono 标 legacy | ~30 行 + 测试更新 | 0.5d | P1 |
|
||||
| P2-2 | install.sh / install.ps1 的 multi 分支**真装**(幂等、读 state 跳过) | 两个脚本各 ~80 行 | 1.5d | P0b(脚本侧写 state) |
|
||||
| P2-3 | install.js / install-skills.sh 加 mode 支持(env + flag,默认 multi) | 各 ~60 行 | 1d | P2-2 同批 |
|
||||
| P2-4 | 文案翻转:README×2、SKILL.md EXPERIMENTAL 下调、install 脚本提示、AGENTS.md | 纯文档 | 0.5d | — |
|
||||
| W4 | mono deprecation 警告(不删代码)+ 观察 | ~20 行 | 0.5d | P2 |
|
||||
|
||||
**小计:核心 13–17 人日**(P0 ≈ 6.5–8.5,P1 ≈ 3–3.5,P2 ≈ 3.5,W4 0.5)。
|
||||
|
||||
可选/后置:rollout.json 分桶 2–3d;`setup --link` symlink 1–2d(需逐 agent
|
||||
验证);npm 薄壳化 2–3d(独立立项)。
|
||||
|
||||
### 8.3 风险最高的两处(先动)
|
||||
|
||||
1. **P0a upgrade multi 刷新语义**:additive 安装 vs upgrade 全量刷新之间存在
|
||||
一个真实设计题 —— 用户手动 `-x` 排除过的 skill,upgrade 要不要装回来?
|
||||
答案:以 state.json 的 `installed` 为准做增量刷新,未装的不得补装
|
||||
(否则违背 additive 语义)。这条必须在 P0a 开工前定死。
|
||||
2. **P0-3 备份回滚改语义**:现有测试断言"清理失败继续装",改语义会动
|
||||
`skill_setup_full_coverage_test.go` 多处;回滚恢复顺序(先恢复再报错)
|
||||
要用故障注入测试覆盖。
|
||||
|
||||
### 8.4 一个月做不完时的砍法
|
||||
|
||||
按价值/成本比从后往前砍:rollout 分桶(L1 beta 轨已够)→ install.js /
|
||||
install-skills.sh mode 支持(npm 渠道用户量小,可先只改 sh/ps1)。砍后**最小可行集 8–10 人日**:
|
||||
|
||||
> P0c-1 + P0b + P0-3 + P0a + P1-1 + P2-1 + P2-2(仅 sh/ps1)+ 文案
|
||||
|
||||
即:upgrade 不再制造双份、有状态可回滚、setup 与主流安装脚本默认 multi。
|
||||
npm/install-skills.sh 维持 mono 显式行为并在输出中标注即将切换。
|
||||
|
||||
## 9. 明确不做
|
||||
|
||||
- 不做服务端远程配置/灰度平台(用 beta 轨 + rollout.json 替代)。
|
||||
- 不动 `dws-skills.zip` 产物布局(已含 mono/multi 双树)。
|
||||
- 不动市场 skill(`dws skill install`)的安装语义。
|
||||
- 并行期内不删 mono 代码与产物,只降级为 opt-in。
|
||||
@@ -0,0 +1,153 @@
|
||||
# Skill multi 迁移:技术方案(as-implemented)与 Roadmap
|
||||
|
||||
> 本文是 multi 迁移的当前事实源:第一部分记录**已落地实现**(代码级锚点,
|
||||
> 均可跳转验证),第二部分是带实时状态的 roadmap。
|
||||
> 原始方案 [skill-multi-migration-plan.md](skill-multi-migration-plan.md) 的
|
||||
> 若干"待做"描述已被后续决策取代(见决策记录 D1/D5);分发机制调研结论见
|
||||
> [skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
|
||||
> 代码快照:`feat/skill-mode-migration` @ `402429ac` + 工作区
|
||||
> upgrade-force-multi 调整(2026-08-05)。
|
||||
|
||||
## 状态速览
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 当前阶段 | **阶段 1 / 1.5 ✅**(安装/升级默认 multi 已落地);**阶段 2 运行时切换产品线 ❌ CANCELLED**(2026-08-05 owner 决策) |
|
||||
| 硬 deadline | **2026-08-30**:安装/升级默认 multi(✅)+ upgrade **有 multi 包时一次性刷成 multi**(不做磁盘粘性)+ mono 仅安装时 opt-in;mono **物理下线**仍可在独立 retirement 版本推进,但**不再**依赖 `dws skill mode` / 备份回滚产品 |
|
||||
| 已完成 | 五面默认 multi、`dws skill setup` 默认 multi、互斥清理对称、文案翻转;upgrade 有 `multi/` 时一律刷新 multi(存量 mono 一次性迁移) |
|
||||
| 下一步 | beta/L1 版本门控与观察(靠 issue/回访,**无** `x-dws-skill-mode` 埋点);可选 agent-home 清单门禁;内容 C 线并行 |
|
||||
| 终态 | mono 仅安装时 opt-in;日常 upgrade(含 multi 的包)刷 multi;mono 物理删除仍可在 dedicated retirement 版本收尾(非用户切换命令) |
|
||||
|
||||
### 简化设计(2026-08-05 起生效)
|
||||
|
||||
1. **安装时一次决定**:默认 multi;`DWS_SKILL_MODE=mono` / `--mode mono` / 安装器 TTY 选 mono 为唯一 opt-in。
|
||||
2. **装完无运行时切换产品**:不做 `dws skill mode set/rollback`,不做备份式安装 / `state.json` 记账产品面。
|
||||
3. **升级不做粘性**:release zip 含 `multi/` 时 **一律** 刷新 multi(清 `dws/`、刷产品 skill + 缓存);仅 legacy 无 multi 树的包回退 mono 路径。存量 mono 在日常 upgrade 上一次性迁到 multi。
|
||||
4. **mono 下线**:安装侧仍可 opt-in;upgrade 已承担「有 multi 包即迁走」;物理删 mono 树可另议 retirement。
|
||||
5. **可观测**:`x-dws-skill-mode` 请求头已按 owner 决策移除;灰度靠 issue + 回访。
|
||||
|
||||
---
|
||||
|
||||
# 第一部分:技术方案(as-implemented)
|
||||
|
||||
## 1. 升级:包驱动 multi 刷新(`dws upgrade`)
|
||||
|
||||
核心语义:**升级不做磁盘粘性**;产物有 `multi/` 时一次性刷成 multi。
|
||||
无需 `state.json`,也无运行时切换命令。
|
||||
|
||||
- `LocateSkillsRoot` 优先返回 zip 内 `multi/`(`internal/upgrade/paths.go`)。
|
||||
- `UpgradeSkillLocations`:包内有 multi 技能树 → **始终** `upgradeMultiSkillLocations`
|
||||
(平铺 `dingtalk-*` + `dws-shared`,删 mono 残留 `dws/`,清过期 multi skill,
|
||||
刷 `~/.dws/skills/multi`);无 multi 树的 legacy 包才走 mono 刷新。
|
||||
- 这是 upgrade 上的一次性迁移,不是 `dws skill mode` 产品。
|
||||
|
||||
测试:`internal/upgrade/paths_multi_test.go`(含
|
||||
`TestUpgradeSkillLocationsMonoDiskMigratesToMulti`)+
|
||||
`internal/app/upgrade_skill_multi_e2e_test.go`。
|
||||
|
||||
## 2. 安装默认 multi(四个脚本面)
|
||||
|
||||
四个脚本安装面默认值全部为 multi,`DWS_SKILL_MODE=mono` 为统一 opt-in,
|
||||
互斥清理双向对称。详见阶段 1 落地说明(`scripts/install.sh` /
|
||||
`install.ps1` / `build/npm/install.js` / `scripts/install-skills.sh`)。
|
||||
|
||||
## 3. `dws skill setup` 默认 multi
|
||||
|
||||
- 非交互未指定 `--mode` 时默认 multi。
|
||||
- 交互选项 multi 在前(默认)、mono 标 legacy。
|
||||
- 仍可用 `dws skill setup --mode mono --yes` **重装**到 mono(这是安装入口,
|
||||
不是 lifecycle 切换产品;无备份/state/rollback 命令)。
|
||||
|
||||
## 4. 文案翻转
|
||||
|
||||
- `README.md` / `README_zh.md`:multi 默认、mono legacy。
|
||||
- `skills/multi/dingtalk-skill/SKILL.md`:去掉 EXPERIMENTAL。
|
||||
|
||||
## 5. 已验证
|
||||
|
||||
- `go test ./internal/upgrade ./internal/app ./test/scripts`(阶段 1 基线)+
|
||||
upgrade-force-multi 单测 / fake-HOME E2E。
|
||||
- 脚本面契约测试暴露 `DWS_SKILL_MODE` 与 mono/multi 选项。
|
||||
|
||||
## 6. 决策记录
|
||||
|
||||
- **D1 升级不依赖 state.json**(仍成立)。不读状态文件;布局由包内容驱动。
|
||||
- **D2 无服务端灰度**。L1 beta 轨 + issue/回访;L2 `rollout.json` 已砍;
|
||||
kill switch = beta 撤回 / 重装 `--mode mono`(无 `skill mode` 命令)。
|
||||
- **D3 生态分发通道已否决**(`npx skills add` 等)。见
|
||||
[skill-distribution-mechanism.md](skill-distribution-mechanism.md)。
|
||||
- **D4 互斥前缀约定**。`dingtalk-*` / `dws-shared` 属 DWS 产品 skill。
|
||||
- **D5 无运行时模式切换(2026-08-05)**。取消阶段 2 的备份式安装、
|
||||
`state.json`、`dws skill mode`(status/set/rollback)、`x-dws-skill-mode`
|
||||
请求头。Mode 只在安装时决定。
|
||||
- **D6 升级不做粘性(2026-08-05)**。有 multi 包时 upgrade 一次性刷成 multi
|
||||
(含存量 mono);legacy 无 multi 树才回退 mono 路径。
|
||||
|
||||
---
|
||||
|
||||
# 第二部分:Roadmap
|
||||
|
||||
## ✅ 阶段 1 / 1.5(已完成,2026-08-05)
|
||||
|
||||
安装/升级默认 multi、五面互斥清理、文案翻转、1.5 review 修复与实机 9/9。
|
||||
HEAD:`402429ac`。
|
||||
|
||||
## ❌ 阶段 2(原切换/状态/备份产品线)— CANCELLED(2026-08-05)
|
||||
|
||||
| 原任务 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 备份式安装(`~/.dws/skills/backup/...`) | ❌ CANCELLED | 不做运行时切换,无需备份回滚产品 |
|
||||
| `~/.dws/skills/state.json` | ❌ CANCELLED | 无切换产品;upgrade 按包刷 multi,不需要状态文件 |
|
||||
| `dws skill mode` status/set/rollback/--dry-run | ❌ CANCELLED | 无运行时切换 UX |
|
||||
| `x-dws-skill-mode` 请求头 | ❌ CANCELLED | owner 决策移除;观测改 issue/回访 |
|
||||
| agent home 清单门禁 | ⬜ 可选 | 与切换产品无关,仍可作工程质量项 |
|
||||
| `upgrade --dry-run` multi 文案 | ⬜ 可选 | 可随 force-multi 语义轻量对齐 |
|
||||
|
||||
## 重构后的 8/30 目标
|
||||
|
||||
**底线**:新装默认 multi;含 `multi/` 的 release 上 `dws upgrade` **一次性刷成
|
||||
multi**(含存量 mono);仅需保持 mono 的用户用安装入口 opt-in 后勿升级到
|
||||
含 multi 的包,或 retirement 前用 setup 重装;mono 物理删除可另议。
|
||||
|
||||
### 建议关键路径(简化)
|
||||
|
||||
```text
|
||||
阶段1默认multi ✅ → upgrade force-multi ✅ → beta/L1(可选)→ 观察(issue/回访)
|
||||
→ stable 默认已是 multi → 可选 mono retirement(删 mono 树 / 安装入口报错)
|
||||
```
|
||||
|
||||
### mono retirement(原阶段 4,重框)
|
||||
|
||||
- **不再**提供 `dws skill mode rollback` 作为用户出口。
|
||||
- 日常 upgrade 已在有 multi 包时迁走存量 mono;retirement 版本可进一步移除
|
||||
install/setup 的 mono 分支与 zip 内 mono 树。
|
||||
- 下线判据改为:issue/回访无系统性 multi P1、mono opt-in 可接受、政策门
|
||||
不再依赖 `skills/mono/SKILL.md`。(原请求头占比判据作废。)
|
||||
|
||||
## 悟空线下线的影响(2026-08-05)
|
||||
|
||||
悟空 bundled-skill 分发线已下线:mono 下线无仓外节奏闸门;`skills/multi/`
|
||||
为唯一 multi 事实源。设计资产留档
|
||||
[skill-wukong-comparison.md](skill-wukong-comparison.md)。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- ❌ 运行时模式切换(`dws skill mode set/rollback`)。
|
||||
- ❌ `state.json` / 备份式安装产品面(随 D5 取消)。
|
||||
- ❌ `x-dws-skill-mode` 请求头。
|
||||
- ❌ 服务端远程配置 / L2 `rollout.json`。
|
||||
- ❌ 运行时「模式切换」产品(含 sticky 伪切换);upgrade 有 multi 时刷 multi
|
||||
是包驱动的一次性迁移,不是 switch UX。
|
||||
- 不动 `dws-skills.zip` 双树布局(仍可含 mono 副本供安装 opt-in);不动市场
|
||||
skill(`dws skill install`)。
|
||||
|
||||
---
|
||||
|
||||
## 风险表
|
||||
|
||||
| 风险 | 现状 | 缓解 |
|
||||
|---|---|---|
|
||||
| 半装无备份 | 安装/清理仍 `RemoveAll` | 接受为非切换产品下的已知限制;重装可收敛 |
|
||||
| mono/multi 漂移 | 无 state;upgrade 有 multi 即刷 multi | 升级后收敛为 multi;安装互斥清理 |
|
||||
| 用户想换模式 | 无 switch 命令 | 文档引导:`dws skill setup --mode <mono\|multi> --yes` 重装 |
|
||||
| 8/30 滑期 | 切换产品线已砍,关键路径缩短 | 聚焦 force-multi upgrade + 默认 multi 稳定 |
|
||||
@@ -0,0 +1,290 @@
|
||||
# DWS Skill mono→multi 灰度能力设计(rollout capability)
|
||||
|
||||
> 本文回答一个问题:**在没有服务端远程配置/灰度平台的前提下,mono→multi
|
||||
> 默认翻转如何灰度发布、如何观测、如何止血**。关联文档:
|
||||
> [skill-multi-roadmap.md](skill-multi-roadmap.md)(迁移事实源,本文展开其
|
||||
> 阶段 3「灰度切流」)、[skill-multi-migration-plan.md](skill-multi-migration-plan.md)
|
||||
> §3.7(灰度与止血的原始设计)。代码锚点快照:`feat/skill-mode-migration`
|
||||
> 工作区未 commit 变更(2026-08-05)。
|
||||
|
||||
---
|
||||
|
||||
## TL;DR 推荐路线
|
||||
|
||||
| 步 | 动作 | 层级 | 前置 |
|
||||
|---|---|---|---|
|
||||
| 1 | 把「五面默认 multi」拆成**版本门控默认**(beta→multi / stable 观察),同一份代码发 beta 轨先吃 | L1 | 无(本文 §2.1) |
|
||||
| 2 | ~~阶段 2 四件套(备份 / state.json / `dws skill mode` / 请求头)~~ | — | **❌ CANCELLED(2026-08-05)**:无运行时模式切换;upgrade 有 multi 时刷 multi(不做粘性) |
|
||||
| 3 | beta 轨观察:issue 流入 + 主动回访(**无**请求头占比) | 人工 | 步 1 |
|
||||
| 4 | stable:默认 multi 已在阶段 1 落地;L2 `rollout.json` 已砍 | — | — |
|
||||
| 5 | kill switch:beta 撤回(已有)/ 公告重装 `dws skill setup --mode mono --yes` | — | 无备份回滚产品 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状盘点:今天可用于灰度的全部旋钮
|
||||
|
||||
### 1.1 旋钮总表
|
||||
|
||||
| # | 旋钮 | 代码锚点 | 生效范围 | 盲区(谁够不着) |
|
||||
|---|---|---|---|---|
|
||||
| K1 | GitHub Release 双轨(stable / prerelease) | `internal/upgrade/github.go:100-106`(`ReleaseTrack`);`internal/app/upgrade.go:870-875`(`upgradeTrack`);release.yml 强制版本→轨映射(`release.yml:547` 含 `-beta.` → prerelease) | `dws upgrade --beta` / `--version vX.Y.Z-beta.N` 的二进制+skill 升级 | 不用 `dws upgrade` 的人;Gitee/OSS 镜像用户(见 K3/K4) |
|
||||
| K2 | npm dist-tag 双轨(`latest` / `beta`) | `release.yml:1761-1762`(prerelease→`beta` tag);发布防倒退 `release.yml:1828-1835`;撤回脚本 `scripts/release/withdraw-release.sh:652-665`(dist-tag 回拨+deprecate) | `npm i dingtalk-workspace-cli@beta` 的新装/重装 | npm 默认安装(`@latest`)用户无感;npm 装完即走、不再 `npm i` 的存量 |
|
||||
| K3 | Gitee 镜像(代码 + release 资产) | main 代码镜像 `.github/workflows/mirror-to-gitee.yml:42-82`;release 资产镜像 `release.yml:1957-1965`(`sync-to-gitee.sh`);安装脚本侧 `DWS_GITEE_REPO` 解析 `scripts/install.sh:18-19`、`scripts/install-skills.sh:21-24` | curl\|sh / install-skills.sh 的国内用户(显式 env 或 GitHub 不可达自动回退) | **`dws upgrade` 不到 Gitee**:upgrade client 只打 GitHub API(`internal/app/upgrade.go:48` → `internal/upgrade/github.go`,包内无任何 Gitee 引用);Gitee release 是否 prerelease 由镜像脚本原样搬运,无独立轨控 |
|
||||
| K4 | OSS 镜像(ossutil 同步) | `scripts/release/sync-to-oss.sh:9-14`(`download/<version>/` + `latest.txt`/`beta.txt` 指针);`release.yml:1897-1908` | 今天:**只写不读**——脚本注释明示「repository installers currently resolve GitHub/Gitee and do not consume these OSS pointers directly」(`sync-to-oss.sh:5-7`) | 所有人(指针无人消费);但 `latest.txt`/`beta.txt` 是天然的**可变 channel 指针**(见 §2.2 设计复用) |
|
||||
| K5 | 安装脚本 env / flag | `DWS_SKILL_MODE`:`scripts/install.sh:17`(解析 `install.sh:220-256`)、`scripts/install.ps1:293-332`、`scripts/install-skills.sh:29-33`;npm `--skill-mode` / env `build/npm/install.js:197-214`;`DWS_VERSION` 指定 beta 版 `install.sh:38` | 新装时的逐台显式控制(CI、内推灰度名单) | 只对**执行安装那一刻**生效;装完无持久化(无 state.json),事后无法得知当初怎么装的;`DWS_VERSION=latest` 在 GitHub 侧只解析 stable(`/releases/latest` 永不指向 prerelease,`install.sh:190-198`),beta 必须显式给版本号 |
|
||||
| K6 | 版本门控默认值(构建期注入) | goreleaser ldflags 注入版本 `.goreleaser.yaml:22`(`internal/app.version=v{{.Version}}`);`prerelease: auto` `.goreleaser.yaml:68` | 同一 commit,beta build 与 stable build 可表现不同默认值(§2.1 切法 B 的机制) | 脚本面拿不到 Go 变量,需各自从「解析出的版本号」重推导(§2.1);homebrew formula 只搬 zip 根(mono 布局)到 pkgshare(`build/homebrew.rb.tmpl:26-31`),formula 本身无 mode 概念 |
|
||||
| K7 | 本地遥测(opt-in) | `internal/shortcut/usage/recorder.go:82-88`(`DWS_USAGE_TRACKING=1`,默认关);只写本地 `~/.dws/usage.jsonl`(`recorder.go:91`) | 高频命令形状挖掘(shortcut P2) | **不上传任何服务端**:对灰度占比测量零贡献;默认关意味着即使上传也无统计意义 |
|
||||
| K8 | 请求头通道(已有上行链路) | MCP 请求统一注入点 `internal/app/runner.go:953-1008`(`resolveIdentityHeaders`,接线于 `runner.go:140/240/597`、`internal/app/recovery_command.go:271/337`);登录权限检查 `internal/auth/oauth_helpers.go:1424-1428`;`x-dws-channel`(`DWS_CHANNEL`)先例 `runner.go:1006-1008`、`oauth_helpers.go:1425-1426` | 服务端(MCP 网关)已能按 header 聚合:`x-dws-agent-id`、`x-dingtalk-dws-agent-code`、`X-Cli-Version` 均在线 | 只有「已登录且发 MCP 请求」的用户可被观测;纯安装未使用、auth 失败前的用户在分子里缺席(占比偏高估,§2.3) |
|
||||
|
||||
### 1.2 结构性盲区(任何旋钮都够不着)
|
||||
|
||||
| 盲区 | 说明 | 出处 |
|
||||
|---|---|---|
|
||||
| ~~悟空 bundled skill 分发线~~(盲区已移除) | 悟空分发线(dws_res → Wukong.app bundled-skills)已于 2026-08-05 决策下线,本盲区随之移除;历史上该线在**本仓库外**、仅有 main CI 成功后的下游触发(`.github/workflows/notify-wukong.yml:13-38`),本仓库默认值翻转管不到它 | 原 roadmap 风险表「悟空线外挂」行(已解除)、阶段 4 原判据 3(已作废) |
|
||||
| homebrew 用户 | formula 只把 zip 根(mono 副本)stage 进 `pkgshare/skills/dws`(`build/homebrew.rb.tmpl:26-31`),caveats 指向 `dws skill setup`(`:33-38`);multi 源不在包内,`setup --mode multi` 只能靠 `~/.dws/skills/multi` 缓存 | 同上,K6 |
|
||||
| 永不升级的存量 mono 用户 | 所有旋钮都作用于「新装/升级/重装」三个时点;不动作的用户一切照旧(这正是灰度的天然保护层) | — |
|
||||
| 安装后不再运行 `dws` 的用户 | K8 观测不到,占比分母缺失 | §2.3 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案设计(分层)
|
||||
|
||||
### 2.1 L1 渠道灰度:beta 轨先吃 multi 默认
|
||||
|
||||
目标:**同一代码、不同 release 轨不同默认值**,stable 用户在观察期内完全无感。
|
||||
|
||||
#### 切法 A:纯流程(零新增代码,不推荐单独使用)
|
||||
|
||||
当前工作区五面已无条件翻转 multi;直接发 beta 即完成「beta 先吃」。
|
||||
问题:main 上的默认值已是 multi,**下一个 stable 无处可躲**——stable 发布
|
||||
窗口一到就必须全切,观察期长短不由人;且中途想给 stable 出补丁版(hotfix)
|
||||
会被迫带上 multi 默认。仅适合「beta 观察期确定短、stable 窗口确定远」的情形。
|
||||
|
||||
#### 切法 B:版本门控默认值(推荐)
|
||||
|
||||
把五面的默认值从「无条件 multi」改为「beta 版本默认 multi,stable 版本默认
|
||||
mono」。判据统一用版本号是否含 `-beta.`(release.yml 已强制版本→轨唯一映射,
|
||||
`release.yml:547`、`.goreleaser.yaml:68`):
|
||||
|
||||
| 面 | 门控取值来源 | 落点 |
|
||||
|---|---|---|
|
||||
| `dws skill setup` / `dws upgrade`(Go) | ldflags 注入的 `internal/app.version`(`.goreleaser.yaml:22`),`strings.Contains(version, "-beta.")` | `internal/app/skill_setup.go:354-357`(非交互默认)与 `:364-368`(交互默认项排序) |
|
||||
| install.sh / install.ps1 / install-skills.sh | 脚本自己解析出的 `$VERSION`(`install.sh:177-198`;Gitee 侧 `install.sh:180-188`)——`case "$VERSION" in *-beta.*)` | `install.sh:220-256`、`install.ps1:293-332`、`install-skills.sh:29` |
|
||||
| npm install.js | 包内 `package.json` 的 `version`(staging 时由 `stage-npm-package.sh` 写入 release 版本) | `build/npm/install.js:197-214` |
|
||||
|
||||
**关键发现——upgrade 路径(2026-08-05 更新)。** skill 升级语义是「跟着 zip
|
||||
产物布局走、不做磁盘粘性」:`LocateSkillsRoot` 恒优先 `multi/`,
|
||||
`UpgradeSkillLocations` 在包内有 multi 时**始终**刷 multi(含存量 mono
|
||||
一次性迁移,清 `dws/`)。若仍要做「stable 观察期不迁 mono」,门控必须落在
|
||||
**是否发布含 multi 的 zip / 是否走 upgrade skill 刷新**,而不是磁盘粘性分支
|
||||
(粘性方案已否决)。当前产品默认接受:含 multi 的 release 上 upgrade = 迁
|
||||
multi。
|
||||
|
||||
切法 B 的安装默认门控(beta→multi / stable→mono)仍可独立存在;与 upgrade
|
||||
force-multi 正交——安装 opt-in mono 的用户一旦升级含 multi 的包会被迁走。
|
||||
|
||||
#### 风险与回退
|
||||
|
||||
| 风险 | 说明 | 回退 |
|
||||
|---|---|---|
|
||||
| 门控不可见 | 默认值随版本号变化,review/测试容易漏 | 每面补契约测试(beta→multi / stable→mono),`test/scripts` 已有同构先例(`test/scripts/install_script_test.go:529-576`) |
|
||||
| 升级迁走 mono | **接受为产品语义**:有 multi 包时 upgrade 一次性刷 multi;需 mono 则 `dws skill setup --mode mono --yes` 重装(装完后再 upgrade 仍可能被迁回) | S1 撤回含 multi 的坏包;S3 公告重装 |
|
||||
| beta 轨整体有毒 | 二进制或 skill 包级事故 | 现有撤回链:`scripts/release/withdraw-release.sh`(GitHub release 撤回 + npm deprecate + dist-tag 回滚,`withdraw-release.sh:652-665`);stable 轨不受影响 |
|
||||
| 版本字符串被仿造 | 本地 `go build` 无版本注入时 `version=""`,门控落 stable 分支(保守方向,正确) | — |
|
||||
|
||||
### 2.2 L2 确定性分桶:随 release 发 `rollout.json`
|
||||
|
||||
L1 的粒度是「轨」:beta 全吃、stable 全不吃。stable 切流若要 5%→100% 的
|
||||
渐进,需要机器级分桶。无服务端,用**随版本分发的只读配置 + 客户端确定性
|
||||
哈希**替代。
|
||||
|
||||
#### rollout.json schema 与发布链路
|
||||
|
||||
作为 release 资产随每个版本发出(进 `dist/`):
|
||||
|
||||
```json
|
||||
{
|
||||
"skill_mode": {
|
||||
"pct": 20,
|
||||
"salt": "skill-mode-2026h2",
|
||||
"note": "mono->multi default rollout for stable track"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `pct`:0–100,`bucket < pct` 的机器默认 multi。
|
||||
- `salt`:分桶盐,换盐=重新洗牌(默认不换,保证跨版本粘性可比)。
|
||||
- 发布链路改动:`scripts/release/post-goreleaser.sh` 生成进 `dist/`;
|
||||
**资产命名空间是精确集合**(`scripts/release/verify-release-artifacts.sh:12-38`,
|
||||
「public release assets must contain exactly the supported files」),必须把
|
||||
`rollout.json` 加进 EXPECTED_ASSETS;stable 晋升门会比较 beta 资产集
|
||||
(`release.yml:833-846`),所以引入该资产的那个 beta 起两轨必须同时带。
|
||||
- checksums.txt 由 dist 自动生成,镜像脚本(Gitee `release.yml:1957-1965`、
|
||||
OSS `sync-to-oss.sh:9-14`)整目录搬运,**rollout.json 自动随资产集流到
|
||||
Gitee/OSS,无需额外接线**。
|
||||
|
||||
#### machine-id 来源:读现成,不新建
|
||||
|
||||
`internal/auth/identity.go` 已有稳定 per-install UUID v4 `machineId`
|
||||
(`identity.go:19-20`、结构体 `:70-76`),持久化在 `~/.dws/identity.json`
|
||||
(`identity.go:51` + `pkg/config/constants.go:177-188`),惰性生成
|
||||
(`EnsureExists` `:115-133`),v1 文件透明迁移(`:98-113`)。分桶直接复用:
|
||||
|
||||
```
|
||||
bucket = int(sha256(machineId + "|" + salt)[:8], 16) % 100
|
||||
```
|
||||
|
||||
边界情况:`identity.json` 首建于首次 MCP 请求链路(`runner.go:954`)。灰度
|
||||
决策发生在 setup/upgrade 时,可能早于任何 MCP 请求——此时按 `EnsureExists`
|
||||
同款语义**就地惰性创建**(best-effort 持久化,失败则用进程内随机值且当次
|
||||
不记账,下次重决)。不引入第二套 `~/.dws/install-id`,避免双事实源。
|
||||
|
||||
脚本面(sh/ps1)做 sha256 分桶要读 JSON + 哈希,复杂且易错;npm install.js
|
||||
用 node crypto 是一行。**范围划定:L2 分桶只在 Go 面(`dws upgrade` /
|
||||
`dws skill setup` / 未来 `dws skill mode`)与 npm install.js 实现**;sh/ps1
|
||||
停在 L1 版本门控(curl 用户全是新装,渠道轨已够;百分比分桶的主战场是存量
|
||||
升级,而升级必过 Go 二进制)。
|
||||
|
||||
#### state.json 的 rollout 字段
|
||||
|
||||
依赖阶段 2 的 `~/.dws/skills/state.json`(P0b,未实现,roadmap 阶段 2):
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "multi",
|
||||
"rollout": {
|
||||
"bucket": 37,
|
||||
"pct": 20,
|
||||
"salt": "skill-mode-2026h2",
|
||||
"decision": "multi",
|
||||
"decided_at": "2026-08-20T08:00:00Z",
|
||||
"decided_by": "rollout.json@v1.4.2",
|
||||
"explicit": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
决策顺序(每台机器只决策一次,粘性):
|
||||
|
||||
1. 本地显式(`DWS_SKILL_MODE` / `--mode` / `--skill-mode` / `dws skill mode set`)
|
||||
→ 用之,`explicit=true`;
|
||||
2. `state.json` 已有 `mode` 或磁盘形态可反推(roadmap 阶段 2 既定兜底)
|
||||
→ 保持现状,不参与分桶;
|
||||
3. `state.rollout.decision` 已存在 → 复用(pct 后续变化不翻案);
|
||||
4. 拉取 `rollout.json`(Go 面:upgrade 已下载本版资产,同 release 再取一个
|
||||
小文件;npm:包内自带)→ 算 bucket 决策并记账;
|
||||
5. 任一步失败 → 当期默认(L1 版本门控结果)。
|
||||
|
||||
#### 三条硬规则
|
||||
|
||||
| 规则 | 内容 | 理由 |
|
||||
|---|---|---|
|
||||
| R1 本地显式优先 | env/flag/命令任何时候压过 rollout 决策;显式选择落 `explicit=true` 后 rollout 永不改它 | 灰度不能覆盖用户意志;也是 kill switch 的用户侧出口 |
|
||||
| R2 拉取失败 fail-safe | 拉不到/解析失败/字段越界 → 保持现状(存量)或当期默认(新装),**绝不因拉取失败翻模式** | 无服务端下网络面即故障面,故障必须倒向保守侧 |
|
||||
| R3 存量不被改模式 | 已有 mode(state 或磁盘可推)的机器不参与分桶;pct 只影响「未决策」机器 | pct 从 20 降到 0 不能把已进 multi 的 20% 弹回 mono(那需要 kill switch,不是分桶语义) |
|
||||
|
||||
#### 与 Gitee / immutable release 的兼容
|
||||
|
||||
- **Gitee**:`dws upgrade` 不读 Gitee(§1.1-K3),rollout.json 经
|
||||
reconcile/sync 脚本随资产集镜像到 Gitee release;Gitee 侧安装脚本如需消费,
|
||||
走与 `dws-skills.zip` 相同的 Gitee API 资产枚举(`install.sh:180-188` 同
|
||||
模式)。一期不消费、只保证镜像不缺失。
|
||||
- **immutable release 约束**:官方仓已开启不可变 release(`release.yml:802`
|
||||
「Immutable releases must be enabled before publishing」),**资产发布后不可
|
||||
替换**——调 pct = 发一个新补丁版(beta 线 release.yml 支持连续 beta:
|
||||
`release_bump` 在连续 beta 线时被忽略,`release.yml:25-27`)。这决定了
|
||||
L2 的调参时延 = 一次发版;追求更快止血见 §2.4。
|
||||
- (可选远期)OSS `latest.txt`/`beta.txt` 是现成的**可变**指针
|
||||
(`sync-to-oss.sh:13-14`),若未来 installer 学会读 OSS,可把
|
||||
`rollout-current.json` 放 OSS 变指针后面实现「不发版调 pct」;今天无消费
|
||||
方,不建。
|
||||
|
||||
### 2.3 L3 可观测性:`x-dws-skill-mode` 请求头 — ❌ CANCELLED
|
||||
|
||||
**2026-08-05 owner 决策:不实现该请求头**(与运行时模式切换 / state.json
|
||||
一并取消)。原设计(注入 `resolveIdentityHeaders`、按 state/磁盘上报
|
||||
`mono|multi|unknown`)仅作历史记录,不进入排期。
|
||||
|
||||
观察手段改为:**issue 反馈 + 主动回访**;不再有请求级 multi 占比判据。
|
||||
|
||||
### 2.4 Kill switch:四层止血
|
||||
|
||||
| 层 | 手段 | 时延 | 现状/依赖 |
|
||||
|---|---|---|---|
|
||||
| S1 beta 轨整体撤回 | `scripts/release/withdraw-release.sh`:GitHub release 撤回 + npm deprecate + `beta` dist-tag 回拨(`:652-665`) | 分钟级 | **今天可用** |
|
||||
| S2 rollout.json `pct=0` | 发补丁版把 pct 打 0(immutable release 不允许原地改资产,§2.2) | 一次发版(小时级) | 依赖 L2 落地 |
|
||||
| S3 公告命令 | 公告用户重装 `dws skill setup --mode mono --yes`(安装入口,非 switch 产品) | 用户触达时延 | **可用**(无 `dws skill mode`;阶段 2 切换命令已 CANCELLED) |
|
||||
| S4 备份回滚 | ~~`dws skill mode rollback` + 备份式安装~~ | — | **❌ CANCELLED(2026-08-05)** 与运行时切换一并取消 |
|
||||
|
||||
二进制侧另有既有的 `dws upgrade --rollback`(`internal/upgrade/rollback.go`,
|
||||
备份在 `~/.dws/data/backups`、保留 5 份 `:16/:54-63`),但只回滚二进制不回滚
|
||||
skill 布局——skill 止血靠 S1 + S3(重装 mono),**无** S4。
|
||||
|
||||
**结论(2026-08-05):** kill switch = S1(beta 撤回)+ S3(公告重装
|
||||
`--mode mono`)。S4 已取消;日常 upgrade 有 multi 包时**一次性刷 multi**
|
||||
(不做粘性),故「装完 mono 再 upgrade」会迁走——止血靠撤回坏包或重装。
|
||||
|
||||
---
|
||||
|
||||
## 3. 对标(简要)
|
||||
|
||||
**npm dist-tag 双轨(lark-cli 类企业内部 CLI 的通行形态)。** 以 npm 仓
|
||||
registry 为唯一分发面时,灰度即 dist-tag:`latest` 稳态、`beta`/`next` 先行
|
||||
(`next@canary`、`typescript@beta` 同款模式),安装侧 `npm i pkg@beta` 或
|
||||
CI 指定 tag 即完成分群;撤回即 `npm dist-tag add pkg@<prev> latest` +
|
||||
`npm deprecate`。DWS 已完整具备此形态(§1.1-K2),lark-cli 等内部 CLI 在
|
||||
集团内网 registry 上亦按同一范式运作——差别只在内部 registry 可附带按
|
||||
员工/部门灰度的下发规则,那是「registry 有服务端」的红利,DWS 面向公网
|
||||
npm 没有这一层,故需 L2 补齐。
|
||||
|
||||
**安装时下载器内版本选择(deno / rustup 模式)。** `curl | sh` 安装器不显式
|
||||
给版本时,先拉一个**可变 channel 指针文件**(如 deno 的
|
||||
`dl.deno.land/release-latest.txt`、rustup 的 channel manifest),再按指针下载
|
||||
真实产物——指针一改全量新装即转向,**不发版即可调流**。DWS 的 OSS
|
||||
`latest.txt`/`beta.txt`(`sync-to-oss.sh:13-14`)已是同构物,只差安装脚本消费
|
||||
它;install.sh 今天直接打 GitHub `/releases/latest` 重定向(`install.sh:190-198`),
|
||||
等价于把 GitHub 当不可调指针用。此模式是指针级灰度,做不到机器级百分比,
|
||||
需与 L2 分桶叠加。
|
||||
|
||||
**双产物并行(VS Code Stable / Insiders 模式)。** 两个渠道各发各的包、用户
|
||||
自选安装,灰度靠「渠道人口结构」自然形成,无需任何运行时门控。DWS 的
|
||||
GitHub prerelease + npm `@beta` 已是它的轻量版(同包不同 tag 而非两个包名),
|
||||
L1 切法 B 的版本门控默认正是把「渠道差异」从纯流程下沉为可测试的代码事实。
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐路线与改动点清单
|
||||
|
||||
### 4.1 路线(与 roadmap 阶段 2/3 对齐后的排序)
|
||||
|
||||
```
|
||||
L1 版本门控拆分(本工作区之上叠加,先合入)
|
||||
→ ~~阶段 2 四件套~~ ❌ CANCELLED(无运行时切换;upgrade force-multi)
|
||||
→ beta 轨发版先吃(L1 自动生效)+ L3 观察 ≥2 周
|
||||
→ stable 切流:小步直接 100%(删门控);若要求渐进再上 L2 分桶
|
||||
→ mono retirement 判据(roadmap:issue/回访,**无**请求头占比)
|
||||
```
|
||||
|
||||
### 4.2 改动点清单(文件级)
|
||||
|
||||
| 项 | 文件 | 改动 | 估时 |
|
||||
|---|---|---|---|
|
||||
| L1-a Go 门控函数 | `internal/app/skill_setup.go`(或新 `internal/upgrade/track.go` 下沉共享) | `defaultSkillModeForVersion(version)`:含 `-beta.`→multi 否则 mono;替换非交互默认与交互排序 | 0.5d |
|
||||
| L1-b upgrade force-multi(已落地) | `internal/upgrade/paths.go` `UpgradeSkillLocations` | 有 multi→始终 multi(含 mono 盘迁移);legacy 无 multi→mono | ✅ |
|
||||
| L1-c 脚本面门控 | `scripts/install.sh` / `install.ps1` / `install-skills.sh` / `build/npm/install.js` | 默认值解析加 `*-beta.*` 分支(若仍做 L1) | 0.5d |
|
||||
| L1-d 契约测试 | `test/scripts` / `internal/upgrade` / `internal/app` | 每面断言 beta→multi;upgrade mono→multi E2E | 0.5–1d |
|
||||
| L3 请求头 | — | **❌ CANCELLED** | — |
|
||||
| L2-a/b/c rollout.json | — | **已砍**(roadmap) | — |
|
||||
| S3/S4 | — | S3=重装 mono(可用);S4 备份/rollback **❌ CANCELLED** | — |
|
||||
|
||||
合计:L1 ≈ 2–2.5d;L3 ≈ 0.5d;L2 ≈ 2.5–3d(在 state.json 之后)。
|
||||
L1+L3 是进入 beta 观察期的最小集;L2 只在 stable 需要渐进切流时才启动,
|
||||
否则删门控一步到位即可。
|
||||
|
||||
### 4.3 明确不做(沿用 roadmap/D2,本文补充)
|
||||
|
||||
- 不建服务端远程配置/灰度平台;不为灰度单独引入可变配置下发通道(OSS 变
|
||||
指针仅作远期可选,今天无消费方)。
|
||||
- 不动 `dws-skills.zip` 产物布局(D1);不按轨发不同 zip——轨差异全部落在
|
||||
版本门控的客户端行为上。
|
||||
- 不用遥测做灰度测量(K7 本地 opt-in 无统计意义),观测只走 K8 请求头。
|
||||
@@ -0,0 +1,340 @@
|
||||
# DWS multi-skill vs 悟空(dws-wukong)分发线对比
|
||||
|
||||
> ⚠️ **留档注记(2026-08-05)**:悟空(dws-wukong)bundled-skill 分发线
|
||||
> 已于 2026-08-05 决策下线。本文自此仅作**历史调研留档**,不再作为任何
|
||||
> 对齐依据——文中的「判据 #3」「对齐清单(§7.1)」「待确认问题(§7.2)」
|
||||
> 等均随悟空线下线而 MOOT。其中 bundle 的自描述打包设计
|
||||
> (`manifest.json` + `scripts/_install.sh`)与 symlink 提升消费方式
|
||||
> 可作为未来打包方案参考保留。
|
||||
>
|
||||
> 撰写日期:2026-08-05。聚焦 **multi-skill** 主题:DWS 侧(本工作区
|
||||
> `feat/skill-mode-migration`)已把 multi 翻转为全通道默认,而 mono 下线
|
||||
> 原判据 #3 曾要求"悟空 bundled skill 分发线(dws_res → bundled-skills)
|
||||
> 已切 multi"([skill-multi-roadmap.md](skill-multi-roadmap.md) 阶段 4、
|
||||
> [skill-multi-migration-plan.md](skill-multi-migration-plan.md) §5;
|
||||
> 该判据已于 2026-08-05 随悟空线下线作废)。
|
||||
> 本文盘清悟空线现状、两边差异、切换影响与对齐清单。
|
||||
>
|
||||
> 配套阅读:[skill-multi-roadmap.md](skill-multi-roadmap.md)(DWS 侧
|
||||
> as-implemented 事实源)、[skill-distribution-mechanism.md](skill-distribution-mechanism.md)
|
||||
> (DWS 分发/消费链路调研)。
|
||||
|
||||
## 0. 摘要(TL;DR)
|
||||
|
||||
- **DWS 侧**:multi(`skills/multi/`,19 个 `dingtalk-*` 产品 skill +
|
||||
`dws-shared`)已是安装(4 脚本面)、升级、`dws skill setup` 五面默认;
|
||||
产物 `dws-skills.zip` 恒含"根 mono 副本 + `mono/` + `multi/`"三树,
|
||||
二进制 embed 双树(`skills_embed.go:28`)。**产物零改动即完成默认翻转**。
|
||||
- **悟空侧**:multi 打包能力**已合入 dws-wukong `develop`**(merge
|
||||
`9eb801e5`,"DWS MultiSkill 与 Qwen Work Cloud 六平台打包"),但
|
||||
**正式发版 target `make real-platform` 仍只打 mono**
|
||||
`dingtalk-workspace.zip`;multi 以 `dingtalk-workspace-bundle.zip` 双包
|
||||
形态存在(`bundle-platform` / `package-dual`),本地有 2026-07-03 的双包
|
||||
实测产物,但**未随已发布版本出门**(`release/0.2.97`–`0.2.99` 均不含该
|
||||
merge)。
|
||||
- **关键缺口在客户端**:RewindDesktop(悟空桌面端)构建期
|
||||
`download_binary.py` 只认 `dingtalk-workspace.zip` 单 zip;运行时
|
||||
`dws_update.rs` 灰度更新也只 upsert 单个 `dingtalk-workspace` skill;
|
||||
全仓 grep 无 `dingtalk-workspace-bundle` / T4b 处理。**端内尚无消费
|
||||
multi bundle 的代码路径**。
|
||||
- **悟空线的 multi 与 DWS 的 multi 是两套独立维护的树**(dws-wukong
|
||||
`dingtalk-skills/` 12 产品 + `dws-shared`,由本仓 mono 机械派生;DWS
|
||||
`skills/multi/` 19 产品 + `dws-shared`),内容靠 SOP 人工对齐,无自动
|
||||
同源。判据 #3 的"切 multi"首先要解决的是**打包形态 + 端内加载**,
|
||||
内容同源是紧随其后的问题。
|
||||
- 结论(历史):判据 #3 远未满足。对齐需要 dws-wukong 仓(发版 target)、
|
||||
RewindDesktop 仓(构建期 + 运行时两条加载路径)两侧改动,详见 §7 清单。
|
||||
**(2026-08-05 MOOT:悟空线下线,判据 #3 已作废——见
|
||||
[skill-multi-roadmap.md](skill-multi-roadmap.md) 阶段 4 判据更新;
|
||||
上述对齐工作不再需要。)**
|
||||
|
||||
## 1. 证据源与版本快照
|
||||
|
||||
本地仓库(均为真实磁盘证据,非仅凭文档):
|
||||
|
||||
| 仓库 | 本地路径 | 核查时状态 |
|
||||
|---|---|---|
|
||||
| DWS(本仓) | `~/GolandProjects/open-source/dws-skill-mode-migration` | `feat/skill-mode-migration`,含未 commit 的阶段 1 变更 |
|
||||
| dws-wukong 主仓 | `~/GolandProjects/open-source/dws-wukong` | checkout `codex/deploy-qwenwork-dev`(落后 develop 602 commits);本文 Makefile/脚本结论均以 `develop` 分支内容(`git show develop:...`)为准 |
|
||||
| dws-wukong multiSkill 工作区 | `~/GolandProjects/open-source/dws-wukong-multiSkill` | detached @ `9eb801e5`(multiSkill merge 本体),`target/` 有 2026-07 实测产物 |
|
||||
| RewindDesktop | `~/IdeaProjects/RewindDesktop` | checkout `dws/0.2.98`;`develop` 上 `DEFAULT_DWS_RES_URL` = pod `0.2.96` |
|
||||
|
||||
关键版本事实:
|
||||
|
||||
| 事实 | 证据 |
|
||||
|---|---|
|
||||
| multiSkill merge `9eb801e5` 已入 `develop` 与本地 `release/0.2.100` | `git branch --contains 9eb801e5` |
|
||||
| `release/0.2.97` / `0.2.98` / `0.2.99` **不含** multiSkill merge;其 `real-platform` 不打 bundle | `git merge-base --is-ancestor` + `git show origin/release/0.2.99:Makefile` |
|
||||
| 当前发给悟空的 pod 包为 mono:`dws_res_mac.zip` 内仅 `dingtalk-workspace.zip`(单 skill,`SKILL.md`+`references/products/*`)+ 双架构二进制 | 主仓 `target/dws_res_mac.zip`(2026-06-01)`unzip -l` 实测 |
|
||||
| 双包形态已实测:`dws_res_mac/` 同时含 `dingtalk-workspace.zip`(924K)与 `dingtalk-workspace-bundle.zip`(1.27M) | multiSkill 工作区 `target/dws_res_mac.zip`(2026-07-03)实测 |
|
||||
| multi bundle 内部布局:`manifest.json` + `scripts/_install.sh` + `skills/<name>/SKILL.md...` 平铺目录 | multiSkill 工作区 `target/dingtalk-workspace.zip`(2026-07-24)实测 |
|
||||
|
||||
**本地未能验证**(详见 §7.2 问题清单):pod 线上当前包内容(需内网
|
||||
SSO);`/Applications/Wukong.app` 未安装在本机(bundled-skills 目录不存在,
|
||||
无法核对在端真实 zip);RewindDesktop 端"T4b 二选一"灰度逻辑(全仓无匹配,
|
||||
疑似未开发或在平台侧);Qwen Work Cloud 六平台打包的实际发布状态。
|
||||
|
||||
## 2. 分发链路对比
|
||||
|
||||
### 2.1 链路全景
|
||||
|
||||
DWS 侧(本仓,multi 已默认):
|
||||
|
||||
```text
|
||||
skills/mono/ ─┬─ go:embed all:skills/mono all:skills/multi (skills_embed.go:28)
|
||||
skills/multi/ ┘ │ `dws skill setup` 默认源(embed 优先)
|
||||
│
|
||||
└─ scripts/release/post-goreleaser.sh:220-248 ──► dws-skills.zip
|
||||
(根 = mono 副本 + mono/ + multi/,三树恒含)
|
||||
│
|
||||
GitHub Release / Gitee / OSS / npm tarball / Homebrew / 专项脚本
|
||||
│
|
||||
install.sh · install.ps1 · install-skills.sh · npm install.js(四面默认 multi)
|
||||
+ `dws skill setup`(第五面默认 multi)+ `dws upgrade`(布局探测→multi 刷新)
|
||||
│
|
||||
各 agent home 平铺 dingtalk-*/(互斥清理 dws/ 与过期 skill)
|
||||
+ ~/.dws/skills/{mono,multi} 双缓存
|
||||
```
|
||||
|
||||
悟空侧(dws-wukong + RewindDesktop,发版线仍 mono):
|
||||
|
||||
```text
|
||||
上游 CLI 仓 ../dingtalk-workspace-cli(go.mod replace,sync-upstream 按
|
||||
CLI_UPSTREAM_TAG 重建 release 分支)──► 只提供 Go 代码,不提供 skill 内容
|
||||
|
||||
dws-wukong 仓内 skill 内容(自维护):
|
||||
dingtalk-workspace/(mono 源,含 overlays/real)
|
||||
├─ build-workspace-zip ──► dingtalk-workspace.zip(mono,单 skill)
|
||||
└─ scripts/sync-monolith-to-multiskill.py ──► dingtalk-skills/(multi 派生树)
|
||||
└─ scripts/build-bundle.sh ──► dingtalk-workspace-bundle.zip
|
||||
(manifest.json + scripts/_install.sh + skills/<name>/)
|
||||
|
||||
make real-platform(发版默认)──► dws_res_{mac,win}.zip
|
||||
= dws 二进制 + dingtalk-workspace.zip(仅 mono)
|
||||
make bundle-platform / package-dual(已合入 develop,未用于正式发版)
|
||||
= dws 二进制 + dingtalk-workspace.zip(mono 兜底)+ dingtalk-workspace-bundle.zip
|
||||
│
|
||||
pod.alibaba-inc.com zipUpload(SSO 浏览器上传,版本号独立递增)
|
||||
│
|
||||
RewindDesktop scripts/download_binary.py(DEFAULT_DWS_RES_URL 手工对齐)
|
||||
│
|
||||
Wukong.app Contents/Resources/resources/
|
||||
├─ dws/bin/dws(二进制)
|
||||
└─ bundled-skills/dingtalk-workspace.zip(原样拷贝的单 zip)
|
||||
│
|
||||
运行时两条路:
|
||||
a) 启动同步 initialize_bundled_skills_from_resources
|
||||
→ 解 zip 到中央技能库 ~/.real/.skills/bundled/dingtalk-workspace
|
||||
(及 ~/.real/users/*/.skills/bundled)
|
||||
b) 灰度自更新 dws_update.rs:Gaea 开关 wukong/dws_auto_update_enabled_v2
|
||||
→ LWP /r/Adaptor/DwsGrayI/getLatest 取 {version,url,sha256}
|
||||
→ 下载 dws_res → 换 seed 二进制 + 换 bundled zip + upsert 单 skill
|
||||
```
|
||||
|
||||
### 2.2 链路对照表
|
||||
|
||||
| 环节 | DWS(本仓) | 悟空线 | 锚点(悟空侧) |
|
||||
|---|---|---|---|
|
||||
| skill 事实源 | `skills/mono` + `skills/multi`(同仓双树,multi 19 产品 + dws-shared) | dws-wukong 仓 `dingtalk-workspace/`(mono 源)→ 派生 `dingtalk-skills/`(12 产品 + dws-shared);**与上游 skills/ 无自动同步** | `scripts/sync-monolith-to-multiskill.py` docstring |
|
||||
| 二进制与 skill 的版本耦合 | embed 进二进制,天然同版 | 二进制来自上游 tag(`go.mod:58` replace + `sync-upstream` pin `CLI_UPSTREAM_TAG`);skill 在 dws-wukong 仓随 `VERSION`/`main.go` 双写发版 | dws-wukong `Makefile` `sync-upstream` |
|
||||
| 打包产物 | `dws-skills.zip`:根 mono 副本 + `mono/` + `multi/`(`post-goreleaser.sh:220-248`);embed 双树 | `dws_res_{mac,win}.zip`:二进制 + `dingtalk-workspace.zip`(mono);双包 target 已存在但未上发版线 | dws-wukong `Makefile` `real-platform` / `bundle-platform` / `package-mac-dual` |
|
||||
| 渠道 | GitHub/Gitee/OSS/npm/Homebrew/专项脚本,7 个安装面 | pod zipUpload(SSO)→ RewindDesktop `download_binary.py` → 客户端 bundle | release skill 文档 + `download_binary.py:72-84` |
|
||||
| 端内安装 | 4 脚本 + `dws skill setup` 平铺到 16 个 agent home(父目录门控) | 构建期拷贝 zip 进 app 资源;启动时解到 `~/.real/.skills/bundled/` | `startup.rs:486-554` |
|
||||
| 运行时更新 | `dws upgrade`(布局探测→multi 刷新 + 互斥清理 + 缓存刷新) | 客户端灰度自更新(Gaea + LWP),整包替换 seed 二进制 + skill zip | `dws_update.rs:107,27-28,440-545` |
|
||||
| 灰度能力 | 无服务端:beta 轨(L1)+ 可选 `rollout.json` 分桶(L2)+ 公告 kill switch(决策 D2) | 有服务端:Gaea 开关 + LWP getLatest + pod 版本号 | 决策记录 D2 vs `dws_update.rs` |
|
||||
|
||||
## 3. skill 布局对比
|
||||
|
||||
### 3.1 三种形态
|
||||
|
||||
| 形态 | 布局 | 消费方 |
|
||||
|---|---|---|
|
||||
| DWS mono | 单 skill:`SKILL.md` + `references/` + `scripts/`,装进 `<agent-home>/dws/` | DWS legacy 安装面;悟空 `dingtalk-workspace.zip` 同构(多 `plugins/`、real overlay) |
|
||||
| DWS multi | 平铺目录树:`multi/dingtalk-<product>/{SKILL.md,references,scripts}` + `multi/dws-shared/`,无 manifest、无安装器,拷贝即平铺到 agent home | DWS 五面默认;`dws upgrade` 探测 `multi/` 树(`internal/upgrade/paths.go:363-369`) |
|
||||
| 悟空 multi bundle | zip 内 `manifest.json`(`{"version":...}`)+ `scripts/_install.sh` + `skills/<name>/` 平铺目录(含 `dws-shared`) | **Qwen Work Cloud 已消费**(`_install.sh` 把 `skills/*` 以 symlink 提升到一层 skills 根,供 Codex/OpenCode 扫描;`.dws-multiskill-current` + `.dws-multiskill-links` 记账);**Wukong.app 尚未消费** |
|
||||
|
||||
注意两种 multi 的"平铺"语义不同:DWS multi 是**裸目录树**,由安装面自己
|
||||
拷贝到各 agent home;悟空 bundle 是**自描述包**(manifest + 安装脚本),
|
||||
由消费方解包后提升。`dws-skills.zip` 的 `multi/` 树与
|
||||
`dingtalk-workspace-bundle.zip` 的 `skills/` 树**布局同构但内容不同源**
|
||||
(见 §3.3)。
|
||||
|
||||
### 3.2 悟空客户端加载约定(RewindDesktop 实测)
|
||||
|
||||
构建期(`scripts/download_binary.py`):
|
||||
|
||||
- `DWS_RES_WORKSPACE_ZIP = "dingtalk-workspace.zip"`(`:103`),
|
||||
`resolve_dws_res_contents`(`:1366-1380`)强制 dws_res 内必须同时有
|
||||
平台二进制和**这个文件名的 zip**——`dingtalk-workspace-bundle.zip` 会被
|
||||
原样忽略(不报错,但也不使用)。
|
||||
- `sync_dingtalk_workspace_bundle`(`:1422-1432`)把该 zip **原样拷贝**到
|
||||
`tauri-app/src-tauri/resources/bundled-skills/dingtalk-workspace.zip`,不解包。
|
||||
|
||||
运行时(`tauri-app/src-tauri/src/skills/startup.rs`):
|
||||
|
||||
- `collect_bundled_skill_sources`(`:486-506`)扫描 `resources/bundled-skills/`:
|
||||
**每个子目录或每个 `.zip` = 一个 skill**,`skill_id = 文件主干名`
|
||||
(`dingtalk-workspace.zip` → skill id `dingtalk-workspace`)。
|
||||
该扫描**天然支持多 skill 平铺**(放 `dingtalk-mail.zip`、`dingtalk-doc.zip`
|
||||
就会被分别注册),但**不认识嵌套 bundle**:若把
|
||||
`dingtalk-workspace-bundle.zip` 丢进去,只会被当成一个名叫
|
||||
`dingtalk-workspace-bundle` 的单 skill 解开(内容是 manifest+skills/ 目录,
|
||||
不会被提升)。
|
||||
- `sync_bundled_skill_source`(`:533-554`)解 zip 拷贝进中央技能库;
|
||||
`cleanup_removed_bundled_skills`(`:508-531`)会删除 bundled-skills 里
|
||||
已不存在的 bundled skill(mono→multi 切换时可自动清掉旧
|
||||
`dingtalk-workspace`,前提是 store 记录完好)。
|
||||
- 灰度自更新 `dws_update.rs:440-545` 硬编码单 skill:
|
||||
`upsert_bundled_skill_from_source(store, DWS_WORKSPACE_SKILL_ID, …)`
|
||||
(`DWS_WORKSPACE_SKILL_ID = "dingtalk-workspace"`),换包 = 替换
|
||||
`bundled-skills/dingtalk-workspace.zip` + 重 upsert 这一个 skill。
|
||||
|
||||
结论:端内**构建期与运行时两条路都按"单 zip 单 skill"接线**;要支持
|
||||
multi,二选一:(a) 端内学会解 bundle(manifest + 提升子 skill),或
|
||||
(b) 打包侧把每个子 skill 打成独立 zip 平铺进 dws_res,复用现有平铺扫描
|
||||
(仅需把 `dws_update.rs` 的单 skill upsert 改为遍历)。详见 §6.2。
|
||||
|
||||
### 3.3 两套 multi 树的集合差异
|
||||
|
||||
| | DWS `skills/multi/` | dws-wukong `dingtalk-skills/`(develop) |
|
||||
|---|---|---|
|
||||
| 产品 skill 数 | 19 | 12 |
|
||||
| 共有(12 个) | aisearch, aitable, calendar, chat, contact, doc, drive, mail, minutes, misc, todo, wiki | 同左 |
|
||||
| 仅 DWS 有(7 个) | dev, event, hrbrain, markdown, pat, profile, skill | — |
|
||||
| 共享层 | `dws-shared` | `dws-shared`(内容独立维护) |
|
||||
| 内容来源 | 本仓直接维护 | 由本仓 mono `dingtalk-workspace/` 经 `sync-monolith-to-multiskill.py` 机械派生(链接改写 + misc 桶归并) |
|
||||
| 场景 skill | 不涉及 | `dingtalk-products-skills/` 23 个 scenario skill **仅保留源码,不进统一发布包**(`build-bundle.sh` 头注释) |
|
||||
|
||||
含义:即使悟空线明天切到 multi 形态,其 multi **内容**与 DWS multi 也不
|
||||
一致(少 7 个产品、各自演化)。判据 #3 只要求"分发线切 multi"(形态),
|
||||
但长期看内容同源(或明确的子集契约)需要一并决策。
|
||||
**(2026-08-05 MOOT:悟空线下线,"DWS skills/multi(19) vs 悟空
|
||||
dingtalk-skills(12) 分歧"的长期统一问题随之作废,无需统一;DWS
|
||||
`skills/multi/` 成为唯一 multi 事实源。)**
|
||||
|
||||
## 4. 版本对齐对比
|
||||
|
||||
| 维度 | DWS | 悟空线 |
|
||||
|---|---|---|
|
||||
| skill↔CLI 耦合 | embed 进二进制,`dws skill setup` 装的就是本二进制版本(`skills_embed.go` + `skill_setup_embed.go`) | 二进制版本 = 上游 tag(`sync-upstream` pin);skill 版本 = dws-wukong `Makefile VERSION` + `main.go version` 双写;**两者只通过"同一次发版动作"对齐,无结构性强约束** |
|
||||
| 产物版本 | `dws-skills.zip` 随 goreleaser 与二进制同 tag 发布 | pod 版本号独立于 dws 版本(右most 段 +1 递增,mac/win 各自一条线,如 mac `0.2.28.0`、win `0.2.2`);RewindDesktop `DEFAULT_DWS_RES_URL` 手工改指 |
|
||||
| 端内版本事实源 | — | 客户端以 `dws --version` 输出为准(`get_dws_version_sync`,失败回退 `versions.json`);skill zip 无独立版本概念(mono zip 内无 manifest) |
|
||||
| multi 包版本 | 无 manifest;版本 = 所属 zip/二进制版本 | bundle 内 `manifest.json` 带 `version`(`build-bundle.sh` 由 `$(VERSION)` 写入)——**multi 形态反而第一次给 skill 包带来了显式版本号** |
|
||||
| 升级时的布局兼容 | 新 zip 恒含 `multi/`,`LocateSkillsRoot` 优先 multi;老 zip 自然落回 mono(决策 D1,产物零改动) | dws_res 布局固定(二进制 + workspace zip);双包形态下 mono zip 保留作兜底(`package-mac-dual` 注释:"端内 validate 必含,bundle 缺失时兜底") |
|
||||
|
||||
## 5. 更新 / 回滚对比
|
||||
|
||||
| 维度 | DWS | 悟空线 |
|
||||
|---|---|---|
|
||||
| 更新触发 | 用户主动 `dws upgrade` / 重装脚本 | 两条:(a) 随客户端版本更新(app 内嵌资源替换 + 启动同步);(b) 运行时灰度自更新(Gaea `dws_auto_update_enabled_v2` + LWP getLatest → 下载整包 → 换 seed 二进制 + 换 bundled zip + upsert skill) |
|
||||
| skill 刷新语义 | 布局探测(multi 优先)+ 按 home 互斥清理(删 `dws/`、过期 `dingtalk-*`/`dws-shared`),清理失败则该 home 不装,杜绝共存(`internal/upgrade/paths.go:217-299`) | 启动同步按 bundled-skills 现状全量对账(新增拷贝、缺失删除,`startup.rs:508-554`);自更新路径是单 zip 替换 + 单 skill upsert(`dws_update.rs:462-513`) |
|
||||
| 回滚 | `dws upgrade --rollback` 只回二进制不回 skill;备份式安装 + `dws skill mode rollback` 是阶段 2 P0,**尚未落地** | 无显式回滚命令;事实回滚 = Gaea 开关关闭/改指旧 pod 版本重新下发,或客户端版本回退;`replace-wukong-skill.sh`(仅存在于 `codex/deploy-qwenwork-dev` 分支)提供人工替换 + 重启 |
|
||||
| 半装保护 | 现状 `RemoveAll` 直删、失败仅 warning(风险表已列,阶段 2 改备份式) | zip 整体替换 + preflight 可写性检查,失败提示重启;粒度为整个 skill 包,无子 skill 级半装概念 |
|
||||
| 离线/内网 | embed 兜底(无网可 setup) | app 内嵌 zip 兜底;灰度通道依赖内网 LWP/pod 可达 |
|
||||
|
||||
## 6. 切换影响分析
|
||||
|
||||
### 6.1 DWS 切 multi 默认 / 最终删 mono 对悟空线的影响点
|
||||
|
||||
| # | 影响点 | 评估 |
|
||||
|---|---|---|
|
||||
| 1 | DWS 删 `skills/mono` 后上游 embed 只剩 multi;悟空二进制由上游 tag 构建,`dws skill setup` 行为随之变 | **低**。悟空客户端不从 embed 装 skill(走 bundled zip);但悟空用户在端内手动跑 `dws skill setup` 时会得到 multi——行为变化需在悟空侧公告 |
|
||||
| 2 | `dws-skills.zip` 布局(根 mono + mono/ + multi/) | **零影响**。悟空线不消费 `dws-skills.zip`;DWS 侧也已承诺不动该产物(决策 D1、"明确不做") |
|
||||
| 3 | dws-wukong 的 mono 内容源 `dingtalk-workspace/` 与上游 `skills/mono` 本就各自维护 | **低(但需注意)**。上游删 mono 不会直接打破 dws-wukong 构建;但两边 mono 的"内容漂移对照基准"消失,dws-wukong mono 将彻底成为孤儿副本,加速与上游 CLI 能力的文档漂移 |
|
||||
| 4 | mono 下线判据 #3 反向卡住 DWS 侧进度 | **高(流程性)**。悟空线一天不切 multi,DWS 就不能物理删 `skills/mono/`(roadmap 风险表"悟空线外挂"行)。**(已解除 2026-08-05:悟空线下线,判据 #3 作废,DWS 侧进度不再受仓外闸门约束)** |
|
||||
|
||||
### 6.2 悟空线"吃 multi"的改造选项
|
||||
|
||||
**选项 A:端内解 bundle(dws-wukong 现有 bundle 产物直接被消费)**
|
||||
|
||||
- dws-wukong 侧:`real-platform` 改打(或加打)`dingtalk-workspace-bundle.zip`
|
||||
——`bundle-platform` / `package-dual` 已就绪,基本零新开发。
|
||||
- RewindDesktop 侧(主要工作量):
|
||||
- `download_binary.py`:`resolve_dws_res_contents` 接受/校验
|
||||
`dingtalk-workspace-bundle.zip`,同步进 `resources/bundled-skills/`;
|
||||
- `startup.rs`:识别 bundle(`manifest.json` + `skills/*`),把每个子
|
||||
skill 注册为独立 bundled skill(逻辑等价于 `_install.sh` 的提升,
|
||||
但落在中央技能库);
|
||||
- `dws_update.rs`:灰度自更新从"单 skill upsert"改为"bundle 全量对账"
|
||||
(可复用启动同步的对账逻辑)。
|
||||
- 优点:与 Qwen Work Cloud 已消费的包形态一致,一份产物两个端;bundle 自
|
||||
带 `manifest.json` 版本号。
|
||||
- 缺点:端内要新增 bundle 解析/提升代码与测试;`skills/` 嵌套布局与现有
|
||||
"一 zip 一 skill"约定不同,需谨慎处理迁移期(旧 mono skill 清理)。
|
||||
|
||||
**选项 B:打包侧拆 zip(端内零新格式)**
|
||||
|
||||
- dws-wukong 侧:新增 target 把 `dingtalk-skills/` 每个子 skill 打成独立
|
||||
zip(`dingtalk-mail.zip` … `dws-shared.zip`)平铺进 dws_res。
|
||||
- RewindDesktop 侧:`download_binary.py` 改为同步多个 zip;
|
||||
`startup.rs` 现有平铺扫描**零改动**(自动注册每个 zip);
|
||||
`dws_update.rs` 仍需从单 skill upsert 改为遍历。
|
||||
- 优点:复用端内现有"一 zip 一 skill"约定,启动路径几乎不动。
|
||||
- 缺点:与 Qwen Work 的 bundle 形态分叉(一份内容两种包);dws_res 内文件
|
||||
数膨胀;`dws-shared` 作为独立 skill id 出现在用户可见列表里需要确认
|
||||
端内展示策略。
|
||||
|
||||
**选项 C(过渡态,事实已在用)**:双包并存——mono zip 兜底 + bundle 灰度,
|
||||
端内按灰度二选一。`package-dual` 的注释已写明此意图("端内 T4b 二选一,
|
||||
monolith 端内 validate 必含,bundle 缺失时兜底"),但**端内 T4b/灰度选择
|
||||
逻辑在 RewindDesktop 尚未找到实现**,当前双包发出去也只会用 mono。
|
||||
|
||||
### 6.3 mono 下线判据 #3 的建议验收方式
|
||||
|
||||
> **(2026-08-05 MOOT:判据 #3 已随悟空线下线作废,本节验收清单不再
|
||||
> 适用,仅留档。)**
|
||||
|
||||
判据原文:"悟空 bundled skill 分发线(dws_res → bundled-skills,本仓库
|
||||
外)已切 multi"(原 [skill-multi-migration-plan.md](skill-multi-migration-plan.md)
|
||||
§5-3;该判据已于 2026-08-05 作废,plan §5 已重新编号)。建议按以下
|
||||
可核查项验收(全绿才算满足):
|
||||
|
||||
1. **发版**:dws-wukong 正式 release 流程(release skill 文档中的
|
||||
`make real-platform` 路径)产出的 dws_res 内含 multi 形态包(bundle 或
|
||||
平铺 zip 集),且 pod 上当前版本即为该形态。
|
||||
2. **构建期消费**:RewindDesktop `develop` 的 `download_binary.py` 把 multi
|
||||
形态同步进 `bundled-skills/`(不再是只认 `dingtalk-workspace.zip`)。
|
||||
3. **运行时消费**:悟空端启动同步后,`~/.real/.skills/bundled/` 下出现
|
||||
`dingtalk-*` 多 skill(而非单个 `dingtalk-workspace`);灰度自更新路径
|
||||
同样支持多 skill 对账。
|
||||
4. **实机回归**:全新安装悟空 → 技能列表出现各 `dingtalk-*` skill 且
|
||||
路由正常;从 mono 旧版升级 → 旧 `dingtalk-workspace` 被清理、无双份
|
||||
派发;灰度通道下发一次 multi 包 → 更新后无残留。
|
||||
5. **回退预案**:悟空侧保留 mono 兜底产物或快速重发能力,直至 DWS 删
|
||||
mono 窗口关闭。
|
||||
|
||||
## 7. 结论
|
||||
|
||||
### 7.1 对齐清单(悟空侧待办,按优先级)
|
||||
|
||||
> **(2026-08-05 MOOT:悟空线下线,本清单整体不再需要执行,仅留档。)**
|
||||
|
||||
| 优先级 | 事项 | 仓库/位置 | 备注 |
|
||||
|---|---|---|---|
|
||||
| P0 | 决策端内 multi 消费方案(选项 A 解 bundle vs 选项 B 平铺 zip) | RewindDesktop + dws-wukong 联合 | 建议 A:与 Qwen Work 已消费形态一致,且 bundle 带版本 manifest |
|
||||
| P0 | `download_binary.py` 支持 multi 形态同步进 bundled-skills | RewindDesktop `scripts/download_binary.py:103,1366-1432` | 选项 A 下识别 `dingtalk-workspace-bundle.zip` |
|
||||
| P0 | 启动同步/技能库支持 bundle 解包与子 skill 注册 | RewindDesktop `tauri-app/src-tauri/src/skills/startup.rs:486-554` | 含旧 mono skill 的迁移清理(现有 `cleanup_removed_bundled_skills` 可复用语义) |
|
||||
| P0 | 灰度自更新支持 multi(单 skill upsert → 多 skill 对账) | RewindDesktop `.../dws_update.rs:440-545` | 否则运行时更新会把 multi 打回 mono |
|
||||
| P1 | 正式发版 target 切 multi(`real-platform` 改打/加打 bundle,或改用 `bundle-platform`/`package-dual`) | dws-wukong `Makefile` | 打包能力已在 develop,缺的是设为默认 + release skill 文档同步更新 |
|
||||
| P1 | 端内灰度选择逻辑落地(双包二选一/T4b,或确认直接全量切) | RewindDesktop(未找到现有实现) | 若选选项 C 过渡则必须 |
|
||||
| P1 | 两套 multi 树的内容同源策略:dws-wukong `dingtalk-skills/`(12 产品)vs DWS `skills/multi/`(19 产品) | dws-wukong + 本仓 | **MOOT(2026-08-05)**:悟空线下线,无需统一;原备注:至少明确"悟空子集"契约与同步 SOP 的归属;7 个缺失产品(dev/event/hrbrain/markdown/pat/profile/skill)是否需要进悟空 |
|
||||
| P2 | `replace-wukong-skill.sh` 等运维脚本支持 bundle 形态 | dws-wukong `scripts/deploy/` | 当前只在特性分支且只处理单 zip |
|
||||
| P2 | 悟空侧公告:端内 `dws skill setup` 行为随上游 embed 变化 | dws-wukong 发版流程 | 对应 §6.1 影响点 1 |
|
||||
| P2 | 判据 #3 验收清单(§6.3)写入 DWS roadmap 并跟踪 | 本仓 `docs/skill-multi-roadmap.md` | 阶段 3 期间启动 |
|
||||
|
||||
### 7.2 待确认问题(本地无法闭环,需找人/仓库确认)
|
||||
|
||||
| # | 问题 | 建议确认方 |
|
||||
|---|---|---|
|
||||
| 1 | pod 线上当前 `dws_res_mac/win` 的版本与内部构成(是否已有人发过双包) | pod.alibaba-inc.com(需内网 SSO)/ 悟空发版 owner |
|
||||
| 2 | "端内 T4b 二选一"灰度逻辑是否已存在(在哪个仓库/平台),还是仅写在 Makefile 注释里的规划 | RewindDesktop 团队 / 悟空端内灰度平台 owner |
|
||||
| 3 | LWP `/r/Adaptor/DwsGrayI/getLatest` 服务端返回的下载 URL 指向何处(pod?另一制品库?),multi 包下发是否需要服务端配合改造 | Adaptor/DwsGrayI 服务端 owner |
|
||||
| 4 | Qwen Work Cloud 六平台打包的发布状态与其对 bundle 的消费方式是否可作为悟空端改造的直接参照 | dws-wukong 仓 owner(merge `9eb801e5` 提交者) |
|
||||
| 5 | 悟空端内是否允许 `dws-shared` 作为独立 bundled skill 暴露(名称/展示/路由策略),还是应内联进各产品 skill | RewindDesktop 技能库 owner |
|
||||
| 6 | dws-wukong `dingtalk-skills/` 与上游 `skills/multi/` 的长期关系:保持派生自本仓 mono,还是改为从上游 multi 同步(**MOOT 2026-08-05**:悟空线下线,无需统一) | dws-wukong + DWS 双侧 owner 联合决策 |
|
||||
| 7 | 悟空线切换的目标时间窗(决定 DWS 阶段 4 判据 #3 的最早可满足点)(**MOOT 2026-08-05**:判据 #3 已作废) | 悟空发版 owner |
|
||||
|
||||
---
|
||||
|
||||
*本文所有"已验证"结论均可按 §1 的仓库路径与文中锚点复查;未能本地验证
|
||||
的项集中在 §7.2。*
|
||||
@@ -12,6 +12,7 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
|
||||
github.com/spf13/cobra v1.10.2
|
||||
@@ -40,7 +41,6 @@ require (
|
||||
github.com/itchyny/timefmt-go v0.1.7 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
|
||||
@@ -140,7 +140,7 @@ func TestCrossPlatformCoverageDirectRuntimeRemainingCoverage(t *testing.T) {
|
||||
products := map[string]bool{}
|
||||
aliases := map[string]string{}
|
||||
tools := map[string]string{}
|
||||
registerDynamicServer(mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}, endpoints, products, aliases, tools)
|
||||
registerDynamicServer(mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}, endpoints, products, aliases, tools, false)
|
||||
registerDynamicServer(mcptypes.ServerDescriptor{
|
||||
Endpoint: "https://server.test",
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
@@ -148,7 +148,7 @@ func TestCrossPlatformCoverageDirectRuntimeRemainingCoverage(t *testing.T) {
|
||||
Tools: []mcptypes.CLITool{{Name: "tool"}, {Name: " "}},
|
||||
ToolOverrides: map[string]mcptypes.CLIToolOverride{"override": {}, " ": {}},
|
||||
},
|
||||
}, endpoints, products, aliases, tools)
|
||||
}, endpoints, products, aliases, tools, false)
|
||||
if endpoints["command"] == "" || aliases["alias"] != "id" || tools["override"] == "" {
|
||||
t.Fatalf("registered dynamic server = %#v %#v %#v", endpoints, aliases, tools)
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func TestCrossPlatformCoverageDirectRuntimeRemainingCoverage(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
if got, ok := directRuntimeToolEndpoint("append-override"); !ok || got != "https://append.test" {
|
||||
if got, ok := directRuntimeEndpoint("", "append-override"); !ok || got != "https://append.test" {
|
||||
t.Fatalf("append override endpoint = %q, %v", got, ok)
|
||||
}
|
||||
}
|
||||
@@ -274,7 +274,7 @@ func TestCrossPlatformCoverageEmbeddedSkillAndTinyCommandsRemainingCoverage(t *t
|
||||
if err := completion.RunE(completion, []string{"other"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog := newCatalogCommand(nil)
|
||||
catalog := newCatalogCommand()
|
||||
catalog.SetOut(io.Discard)
|
||||
if err := catalog.RunE(catalog, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -12,7 +12,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -26,6 +29,20 @@ var (
|
||||
)
|
||||
|
||||
func newAuditCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/audit.json
|
||||
// products.audit). Catalog assembly stamps provenance contract_final.
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "audit",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "查看、导出和校验本地操作审计日志",
|
||||
UseWhen: []string{
|
||||
"需要排查本机 CLI 操作审计记录,或验证审计文件完整性",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"查钉钉业务数据或发消息请用对应产品命令,不要用 audit",
|
||||
},
|
||||
},
|
||||
})
|
||||
cmd := &cobra.Command{
|
||||
Use: "audit",
|
||||
Short: "操作审计日志管理",
|
||||
@@ -64,6 +81,39 @@ func newAuditTailCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&n, "lines", "n", 20, "显示最近 N 条记录")
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "audit",
|
||||
Name: "tail",
|
||||
CanonicalPath: "audit.tail",
|
||||
CLIPath: "audit tail",
|
||||
PrimaryCLIPath: "audit tail",
|
||||
},
|
||||
Description: "查看本地操作审计日志最近 N 条记录",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
Reason: "命令读取本地审计日志尾部,不绑定 pinned MCP RPC",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查看本地操作审计日志最近 N 条记录",
|
||||
UseWhen: []string{"需要快速查看最近写入的审计记录(默认最近 20 条)"},
|
||||
AvoidWhen: []string{
|
||||
"需要按日期范围整段导出用 audit export",
|
||||
"需要校验哈希链用 audit verify",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws audit tail",
|
||||
"dws audit tail --lines 50",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -99,6 +149,39 @@ func newAuditExportCommand() *cobra.Command {
|
||||
cmd.Flags().StringVar(&since, "since", "", "起始日期 (YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&until, "until", "", "截止日期 (YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&format, "format", "jsonl", "输出格式: jsonl 或 csv")
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "audit",
|
||||
Name: "export",
|
||||
CanonicalPath: "audit.export",
|
||||
CLIPath: "audit export",
|
||||
PrimaryCLIPath: "audit export",
|
||||
},
|
||||
Description: "按日期范围导出本地操作审计日志(jsonl 或 csv)",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
Reason: "命令读取并导出本地审计日志文件,不绑定 pinned MCP RPC",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按日期范围导出本地操作审计日志(jsonl 或 csv)",
|
||||
UseWhen: []string{"需要把本地审计日志导出为 jsonl/csv,或按 --since/--until 取一段时间"},
|
||||
AvoidWhen: []string{
|
||||
"只看最近几条用 audit tail",
|
||||
"只校验哈希链完整性用 audit verify",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws audit export --format jsonl",
|
||||
"dws audit export --since 2026-07-01 --until 2026-07-14 --format csv",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -152,6 +235,36 @@ func newAuditVerifyCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&file, "file", "", "指定审计文件路径(默认最新文件)")
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "audit",
|
||||
Name: "verify",
|
||||
CanonicalPath: "audit.verify",
|
||||
CLIPath: "audit verify",
|
||||
PrimaryCLIPath: "audit verify",
|
||||
},
|
||||
Description: "校验本地审计日志文件的哈希链完整性",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
Reason: "命令校验本地审计日志哈希链,不绑定 pinned MCP RPC",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "校验本地审计日志文件的哈希链完整性",
|
||||
UseWhen: []string{"怀疑审计文件被篡改,或需要确认最新/指定文件哈希链是否完整"},
|
||||
AvoidWhen: []string{"只浏览或导出日志内容时用 audit tail / audit export"},
|
||||
Examples: []string{
|
||||
"dws audit verify",
|
||||
"dws audit verify --file /path/to/audit.jsonl",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newCatalogCommand(_ cli.CatalogLoader) *cobra.Command {
|
||||
func newCatalogCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "catalog",
|
||||
Short: "查看服务目录 (静态端点模式)",
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
eventbus "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
@@ -288,20 +287,18 @@ func TestCrossPlatformCoverageRecoveryPureCoverage(t *testing.T) {
|
||||
if _, err := (*recoveryRuntime)(nil).CallToolDirect(context.Background(), "x", "y", nil); err == nil {
|
||||
t.Fatal("nil recovery runtime call succeeded")
|
||||
}
|
||||
if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil {
|
||||
t.Fatal("missing recovery endpoint succeeded")
|
||||
}
|
||||
loaderErr := errors.New("catalog")
|
||||
runtime := &recoveryRuntime{loader: cli.CatalogLoaderFrom(cli.Catalog{}, loaderErr)}
|
||||
if _, err := runtime.resolveEndpoint(context.Background(), "missing", "tool"); !errors.Is(err, loaderErr) {
|
||||
t.Fatalf("catalog recovery error = %v", err)
|
||||
}
|
||||
runtime.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{{ID: "empty"}, {ID: "ok", Endpoint: " https://catalog.test "}}}}
|
||||
if _, err := runtime.resolveEndpoint(context.Background(), "empty", "tool"); err == nil {
|
||||
t.Fatal("empty catalog endpoint succeeded")
|
||||
if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
|
||||
t.Fatalf("missing recovery endpoint error = %v", err)
|
||||
} else {
|
||||
var apiErr *apperrors.Error
|
||||
if !errors.As(err, &apiErr) || apiErr.Category != apperrors.CategoryAPI || apiErr.Operation != "discovery.resolve" || apiErr.Reason != "endpoint_not_resolved" {
|
||||
t.Fatalf("missing recovery endpoint classification = %#v", err)
|
||||
}
|
||||
}
|
||||
t.Setenv("DINGTALK_OK_MCP_URL", " https://catalog.test ")
|
||||
runtime := &recoveryRuntime{}
|
||||
if got, err := runtime.resolveEndpoint(context.Background(), "ok", "tool"); err != nil || got != "https://catalog.test" {
|
||||
t.Fatalf("catalog endpoint = %q %v", got, err)
|
||||
t.Fatalf("recovery endpoint override = %q %v", got, err)
|
||||
}
|
||||
if recoveryRuntimeToken(nil) != "" || recoveryRuntimeToken(&GlobalFlags{Token: " token "}) != "token" {
|
||||
t.Fatal("recovery token mismatch")
|
||||
@@ -451,11 +448,14 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
||||
},
|
||||
}
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{{CLI: mcptypes.CLIOverlay{Skip: true}}, server, {CLI: mcptypes.CLIOverlay{ID: "empty"}}})
|
||||
if got, ok := directRuntimeToolEndpoint("tool"); !ok || got != "https://one.test" {
|
||||
if got, ok := directRuntimeEndpoint("unknown", "tool"); !ok || got != "https://one.test" {
|
||||
t.Fatalf("tool endpoint = %q %v", got, ok)
|
||||
}
|
||||
if _, ok := directRuntimeToolEndpoint(" "); ok {
|
||||
t.Fatal("blank tool endpoint resolved")
|
||||
if got, ok := directRuntimeEndpoint("unknown", "override"); !ok || got != "https://one.test" {
|
||||
t.Fatalf("tool override endpoint = %q %v", got, ok)
|
||||
}
|
||||
if _, ok := directRuntimeEndpoint("unknown", "skip"); ok {
|
||||
t.Fatal("server-override tool endpoint resolved")
|
||||
}
|
||||
for _, id := range []string{"one", "cmd", "alias"} {
|
||||
if got, ok := directRuntimeEndpoint(id, ""); !ok || got != "https://one.test" {
|
||||
@@ -469,11 +469,6 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
||||
t.Fatalf("direct runtime IDs = %#v", ids)
|
||||
}
|
||||
|
||||
t.Setenv(cli.CatalogFixtureEnv, "fixture")
|
||||
if shouldUseDirectRuntime(executor.Invocation{Kind: "helper_invocation"}) {
|
||||
t.Fatal("fixture should disable direct runtime")
|
||||
}
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
if !shouldUseDirectRuntime(executor.Invocation{Kind: "helper_invocation"}) || !shouldUseDirectRuntime(executor.Invocation{Kind: "compat_invocation"}) || shouldUseDirectRuntime(executor.Invocation{}) {
|
||||
t.Fatal("direct runtime kind mismatch")
|
||||
}
|
||||
@@ -798,7 +793,6 @@ func TestCrossPlatformCoverageRuntimeRunnerRoutingCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
r.transport = transport.NewClient(nil)
|
||||
r.loader = cli.StaticLoader{}
|
||||
r.globalFlags = &GlobalFlags{Mock: true}
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["content"] == nil {
|
||||
t.Fatalf("mock route = %#v %v", got, err)
|
||||
@@ -809,18 +803,16 @@ func TestCrossPlatformCoverageRuntimeRunnerRoutingCoverage(t *testing.T) {
|
||||
}
|
||||
t.Setenv("DINGTALK_PRODUCT_MCP_URL", "")
|
||||
|
||||
// Dry-run is an execution barrier enforced by Run before endpoint
|
||||
// resolution, so a dry-run invocation returns a local preview and never
|
||||
// reaches handleCatalogMiss or the fallback runner.
|
||||
r.globalFlags = &GlobalFlags{DryRun: true}
|
||||
r.loader = cli.CatalogLoaderFrom(cli.Catalog{}, errors.New("load failure"))
|
||||
if _, err := r.runSingle(context.Background(), inv, false); err == nil || !strings.Contains(err.Error(), "load failure") {
|
||||
t.Fatalf("catalog failure = %v", err)
|
||||
}
|
||||
r.loader = cli.StaticLoader{}
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["fallback"] != true || !fallback.last.DryRun {
|
||||
t.Fatalf("dry catalog miss = %#v %v (invocation %#v)", got, err, fallback.last)
|
||||
if got, err := r.Run(context.Background(), inv); err != nil || got.Response["dry_run"] != true || got.Response["fallback"] != nil {
|
||||
t.Fatalf("dry endpoint miss = %#v %v", got, err)
|
||||
}
|
||||
r.globalFlags = &GlobalFlags{}
|
||||
if _, err := r.runSingle(context.Background(), inv, false); err == nil {
|
||||
t.Fatal("catalog miss succeeded")
|
||||
if _, err := r.runSingle(context.Background(), inv, false); err == nil || !strings.Contains(err.Error(), "no dynamic endpoint registered for product or tool") {
|
||||
t.Fatalf("endpoint miss = %v", err)
|
||||
}
|
||||
devInv := inv
|
||||
devInv.CanonicalProduct = devappProductID
|
||||
@@ -828,29 +820,20 @@ func TestCrossPlatformCoverageRuntimeRunnerRoutingCoverage(t *testing.T) {
|
||||
t.Fatal("devapp catalog miss succeeded")
|
||||
}
|
||||
|
||||
product := cli.CanonicalProduct{ID: "product", Endpoint: "https://catalog.test", Tools: []cli.ToolDescriptor{{RPCName: "tool"}}}
|
||||
r.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}}
|
||||
r.globalFlags = &GlobalFlags{DryRun: true}
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["endpoint"] != "https://catalog.test" {
|
||||
t.Fatalf("catalog dry route = %#v %v", got, err)
|
||||
}
|
||||
product.Tools = nil
|
||||
r.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}}
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: "https://direct.test", CLI: mcptypes.CLIOverlay{ID: "product", Tools: []mcptypes.CLITool{{Name: "tool"}}}}})
|
||||
t.Cleanup(func() { SetDynamicServers(nil) })
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["endpoint"] != "https://direct.test" {
|
||||
t.Fatalf("undeclared tool direct route = %#v %v", got, err)
|
||||
directInv := inv
|
||||
directInv.Kind = "helper_invocation"
|
||||
directInv.DryRun = true
|
||||
if got, err := r.runSingle(context.Background(), directInv, false); err != nil || got.Response["dry_run"] != true || got.Response["endpoint"] != "https://direct.test" {
|
||||
t.Fatalf("direct runtime route = %#v %v", got, err)
|
||||
}
|
||||
SetDynamicServers(nil)
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["fallback"] != true || !fallback.last.DryRun {
|
||||
t.Fatalf("undeclared tool dry miss = %#v %v (invocation %#v)", got, err, fallback.last)
|
||||
}
|
||||
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: "https://owner.test", CLI: mcptypes.CLIOverlay{ID: "owner", Tools: []mcptypes.CLITool{{Name: "tool"}}}}})
|
||||
product.Tools = []cli.ToolDescriptor{{RPCName: "tool"}}
|
||||
r.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}}
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["endpoint"] != "https://owner.test" {
|
||||
t.Fatalf("tool owner correction = %#v %v", got, err)
|
||||
// With no dynamic endpoint registered, a dry-run helper invocation is still
|
||||
// stopped by the Run barrier before endpoint resolution.
|
||||
r.globalFlags = &GlobalFlags{DryRun: true}
|
||||
if got, err := r.Run(context.Background(), directInv); err != nil || got.Response["dry_run"] != true || got.Response["fallback"] != nil {
|
||||
t.Fatalf("direct runtime dry miss = %#v %v", got, err)
|
||||
}
|
||||
|
||||
for _, result := range []executor.Result{{}, {Response: map[string]any{"content": "value"}}, {Response: map[string]any{"value": 1}}} {
|
||||
@@ -961,7 +944,7 @@ func TestCrossPlatformCoverageExecuteInvocationCoverage(t *testing.T) {
|
||||
t.Fatalf("stdio dry-run = %#v %v", got, err)
|
||||
}
|
||||
stdioInv.DryRun = false
|
||||
if _, err := r.executeStdioInvocation(context.Background(), stdioInv); err == nil {
|
||||
if _, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", stdioInv); err == nil {
|
||||
t.Fatal("missing stdio client succeeded")
|
||||
}
|
||||
}
|
||||
@@ -2210,8 +2193,8 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output, warnings, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") || warnings == "" {
|
||||
t.Fatalf("multi setup = %q / %q, %v", output, warnings, err)
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") {
|
||||
t.Fatalf("multi setup = %q, %v", output, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws-shared", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2231,8 +2214,11 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
t.Fatalf("invalid setup %#v succeeded", args)
|
||||
}
|
||||
}
|
||||
if _, _, err := run("--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
|
||||
t.Fatalf("default mono setup: %v", err)
|
||||
if _, _, err := run("--mode", "mono", "--source", mono, "--target", "agents", "--yes", "--dry-run"); err != nil {
|
||||
t.Fatalf("mono setup: %v", err)
|
||||
}
|
||||
if output, _, err := run("--source", multi, "--target", "agents", "--yes", "--dry-run"); err != nil || !strings.Contains(output, "mode=multi") {
|
||||
t.Fatalf("default mode should be multi: %q, %v", output, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2265,7 +2251,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
if _, err := listMultiSkillNames(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing multi source succeeded")
|
||||
}
|
||||
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMono {
|
||||
if mode, err := resolveSkillSetupMode("", true, io.Discard); err != nil || mode != skillSetupModeMulti {
|
||||
t.Fatalf("default setup mode = %q, %v", mode, err)
|
||||
}
|
||||
if _, err := resolveSkillSetupMode("bad", true, io.Discard); err == nil {
|
||||
@@ -2308,8 +2294,8 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
_ = agentHomeForMode("base", skillSetupModeMulti)
|
||||
_ = detectExistingAgentHomes(t.TempDir(), skillSetupModeMono)
|
||||
for _, mode := range []string{skillSetupModeMono, skillSetupModeMulti, "bad"} {
|
||||
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all)
|
||||
_ = mutualExclusionVictims(root, mode)
|
||||
_, _ = confirmSkillSetup(io.Discard, mode, root, []string{root}, all, false)
|
||||
_, _ = mutualExclusionVictims(root, mode)
|
||||
}
|
||||
if isCharDevice(nil) || isInteractiveTerminal() {
|
||||
t.Fatal("test process unexpectedly interactive")
|
||||
@@ -2317,17 +2303,17 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
_ = os.MkdirAll(filepath.Join(filepath.Dir(monoDest), "dingtalk-old"), 0o755)
|
||||
_ = mutualExclusionVictims(monoDest, skillSetupModeMono)
|
||||
_, _ = mutualExclusionVictims(monoDest, skillSetupModeMono)
|
||||
multiDest := filepath.Join(t.TempDir(), "agent")
|
||||
_ = os.MkdirAll(filepath.Join(multiDest, "dws"), 0o755)
|
||||
_ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
|
||||
_, _ = mutualExclusionVictims(multiDest, skillSetupModeMulti)
|
||||
cleanupMutualExclusion(monoDest, skillSetupModeMono, io.Discard, io.Discard)
|
||||
cleanupMutualExclusion(multiDest, skillSetupModeMulti, io.Discard, io.Discard)
|
||||
|
||||
badParent := filepath.Join(t.TempDir(), "file")
|
||||
_ = os.WriteFile(badParent, []byte("x"), 0o600)
|
||||
_, _, _ = installSkillToHomes(root, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
|
||||
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard)
|
||||
_, _, _ = installMultiSkillToHomes(root, []string{"missing"}, []string{filepath.Join(badParent, "dest")}, io.Discard, io.Discard, true)
|
||||
if err := copyDir(filepath.Join(root, "missing"), t.TempDir()); err == nil {
|
||||
t.Fatal("copy missing directory succeeded")
|
||||
}
|
||||
|
||||
@@ -298,11 +298,11 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
|
||||
deduplicateCommands(dedupRoot)
|
||||
addPluginCommandsSafe(dedupRoot, []*cobra.Command{{Use: "same"}, {Use: "new"}})
|
||||
_ = newCompletionCommand(dedupRoot)
|
||||
_ = newCatalogCommand(nil)
|
||||
_ = newCatalogCommand()
|
||||
_ = newConfigCommand()
|
||||
_ = newCacheCommand()
|
||||
_ = newVersionCommand()
|
||||
_ = newRecoveryCommand(context.Background(), nil, &GlobalFlags{})
|
||||
_ = newRecoveryCommand(&GlobalFlags{})
|
||||
_ = newAPICommand(&GlobalFlags{})
|
||||
_ = NewRootCommand(context.Background())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageTokenResolutionErrorBranches(t *testing.T) {
|
||||
if err := tokenResolutionError(nil); err != nil {
|
||||
t.Fatalf("nil error must pass through: %v", err)
|
||||
}
|
||||
if err := tokenResolutionError(context.Canceled); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("context cancellation must pass through unwrapped: %v", err)
|
||||
}
|
||||
if err := tokenResolutionError(context.DeadlineExceeded); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("deadline exceeded must pass through unwrapped: %v", err)
|
||||
}
|
||||
if err := tokenResolutionError(authpkg.ErrTokenDataNotFound); err == nil ||
|
||||
!strings.Contains(err.Error(), "dws auth login") {
|
||||
t.Fatalf("not-found must become a login guidance error: %v", err)
|
||||
}
|
||||
generic := errors.New("keychain locked")
|
||||
if err := tokenResolutionError(generic); err == nil ||
|
||||
!strings.Contains(err.Error(), "resolve access token") || !errors.Is(err, generic) {
|
||||
t.Fatalf("generic error must wrap with resolve access token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProfileSwitchCellsSameCorp(t *testing.T) {
|
||||
profile := authpkg.Profile{CorpID: "corp-1", CorpName: "钉钉", UserName: "Alice", UserID: "u1"}
|
||||
cfg := &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{
|
||||
profile,
|
||||
{CorpID: "corp-1", CorpName: "钉钉", UserName: "Bob", UserID: "u2"},
|
||||
}}
|
||||
org, _ := profileSwitchProfileCells(profile, cfg)
|
||||
if !strings.Contains(org, " / Alice") {
|
||||
t.Fatalf("same-corp label must disambiguate with user name: %q", org)
|
||||
}
|
||||
|
||||
noName := authpkg.Profile{CorpID: "corp-1", CorpName: "钉钉", UserID: "u1"}
|
||||
org, _ = profileSwitchProfileCells(noName, cfg)
|
||||
if !strings.Contains(org, " / u1") {
|
||||
t.Fatalf("blank user name must fall back to user id: %q", org)
|
||||
}
|
||||
|
||||
blankUser := authpkg.Profile{CorpID: "corp-1", CorpName: "钉钉"}
|
||||
org, _ = profileSwitchProfileCells(blankUser, cfg)
|
||||
if strings.Contains(org, " / ") {
|
||||
t.Fatalf("blank user name and id must not append a suffix: %q", org)
|
||||
}
|
||||
|
||||
org, _ = profileSwitchProfileCells(profile, nil)
|
||||
if strings.Contains(org, " / ") {
|
||||
t.Fatalf("nil config must not append a suffix: %q", org)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthRefreshRetryHelpers(t *testing.T) {
|
||||
var nilErr *authRefreshFailureError
|
||||
if got := nilErr.Unwrap(); got != nil {
|
||||
t.Fatalf("nil receiver Unwrap = %v, want nil", got)
|
||||
}
|
||||
rejection := errors.New("rejected")
|
||||
refresh := errors.New("refresh boom")
|
||||
wrapped := (&authRefreshFailureError{rejection: rejection, refresh: refresh}).Unwrap()
|
||||
if len(wrapped) != 2 || !errors.Is(wrapped[0], rejection) || !errors.Is(wrapped[1], refresh) {
|
||||
t.Fatalf("Unwrap must surface both causes: %v", wrapped)
|
||||
}
|
||||
|
||||
if ctx := withAuthRetrying(nil); ctx == nil || !IsAuthRetrying(ctx) {
|
||||
t.Fatal("withAuthRetrying(nil) must produce a retry-marked background context")
|
||||
}
|
||||
|
||||
var nilRunner *runtimeRunner
|
||||
if nilRunner.managesRuntimeOAuth(false) {
|
||||
t.Fatal("nil runner must not manage runtime OAuth")
|
||||
}
|
||||
if (&runtimeRunner{}).managesRuntimeOAuth(true) {
|
||||
t.Fatal("plugin-owned auth must opt out of runtime OAuth management")
|
||||
}
|
||||
if !(&runtimeRunner{}).managesRuntimeOAuth(false) {
|
||||
t.Fatal("nil global flags must manage runtime OAuth")
|
||||
}
|
||||
if !(&runtimeRunner{globalFlags: &GlobalFlags{}}).managesRuntimeOAuth(false) {
|
||||
t.Fatal("blank token must manage runtime OAuth")
|
||||
}
|
||||
if (&runtimeRunner{globalFlags: &GlobalFlags{Token: "tok"}}).managesRuntimeOAuth(false) {
|
||||
t.Fatal("explicit token must not manage runtime OAuth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageForceRefreshRejectedGuards(t *testing.T) {
|
||||
if _, err := ForceRefreshAccessToken(context.Background(), " "); err == nil ||
|
||||
!strings.Contains(err.Error(), "config directory is empty") {
|
||||
t.Fatalf("blank config dir error = %v", err)
|
||||
}
|
||||
testseam.Swap(t, &loadRefreshTokenData, func(string) (*authpkg.TokenData, error) { return nil, nil })
|
||||
if _, err := ForceRefreshAccessToken(context.Background(), t.TempDir()); err == nil ||
|
||||
!strings.Contains(err.Error(), "stored access token is empty") {
|
||||
t.Fatalf("nil token data must report stored access token is empty: %v", err)
|
||||
}
|
||||
if _, err := forceRefreshRejectedAccessToken(context.Background(), " ", "tok"); err == nil ||
|
||||
!strings.Contains(err.Error(), "config directory is empty") {
|
||||
t.Fatalf("blank config dir error = %v", err)
|
||||
}
|
||||
if _, err := forceRefreshRejectedAccessToken(context.Background(), t.TempDir(), " "); err == nil ||
|
||||
!strings.Contains(err.Error(), "rejected access token is empty") {
|
||||
t.Fatalf("blank rejected token error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGetCachedRuntimeTokenSeam(t *testing.T) {
|
||||
token, err := getCachedRuntimeToken(context.Background())
|
||||
if err != nil && strings.TrimSpace(token) != "" {
|
||||
t.Fatalf("failed token resolution must not return a token: %q / %v", token, err)
|
||||
}
|
||||
}
|
||||
+35
-120
@@ -16,12 +16,10 @@ package app
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -112,54 +110,9 @@ func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
|
||||
products := make(map[string]bool)
|
||||
aliases := make(map[string]string)
|
||||
toolEndpoints := make(map[string]string)
|
||||
registerDynamicServer(defaultPATServerDescriptor(), endpoints, products, aliases, toolEndpoints)
|
||||
registerDynamicServer(defaultPATServerDescriptor(), endpoints, products, aliases, toolEndpoints, false)
|
||||
for _, server := range servers {
|
||||
if server.CLI.Skip {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
endpoints[id] = endpoint
|
||||
products[id] = true
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
endpoints[cmd] = endpoint
|
||||
products[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias != "" && endpoint != "" {
|
||||
endpoints[alias] = endpoint
|
||||
products[alias] = true
|
||||
// Build alias → CLI.ID mapping
|
||||
aliases[alias] = id
|
||||
}
|
||||
}
|
||||
// Build tool → endpoint mapping from CLI tools and overrides.
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
// Leaves with serverOverride are routed to a different server's
|
||||
// endpoint (e.g. chat's "search_my_robots" → bot). Registering
|
||||
// them here would overwrite the real owner's tool → endpoint
|
||||
// mapping and send the invocation to the wrong MCP URL.
|
||||
if strings.TrimSpace(override.ServerOverride) != "" {
|
||||
continue
|
||||
}
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
registerDynamicServer(server, endpoints, products, aliases, toolEndpoints, false)
|
||||
}
|
||||
dynamicEndpoints = endpoints
|
||||
dynamicProducts = products
|
||||
@@ -167,7 +120,13 @@ func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
|
||||
dynamicToolEndpoints = toolEndpoints
|
||||
}
|
||||
|
||||
func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
|
||||
// registerDynamicServer is the shared dynamic-server registration core used by
|
||||
// SetDynamicServers and AppendDynamicServer. It records the descriptor's
|
||||
// endpoint under its ID, command name, and aliases, and builds the tool →
|
||||
// endpoint map from its CLI tools and overrides. With protectCommandKey=true
|
||||
// (AppendDynamicServer) an already-registered command-key endpoint is never
|
||||
// overwritten; with false the entry is replaced.
|
||||
func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string, protectCommandKey bool) {
|
||||
if server.CLI.Skip {
|
||||
return
|
||||
}
|
||||
@@ -179,7 +138,13 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
endpoints[cmd] = endpoint
|
||||
if protectCommandKey {
|
||||
if _, exists := endpoints[cmd]; !exists {
|
||||
endpoints[cmd] = endpoint
|
||||
}
|
||||
} else {
|
||||
endpoints[cmd] = endpoint
|
||||
}
|
||||
products[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
@@ -187,11 +152,11 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin
|
||||
if alias != "" && endpoint != "" {
|
||||
endpoints[alias] = endpoint
|
||||
products[alias] = true
|
||||
// Build alias -> CLI.ID mapping.
|
||||
// Build alias → CLI.ID mapping.
|
||||
aliases[alias] = id
|
||||
}
|
||||
}
|
||||
// Build tool -> endpoint mapping from CLI tools and overrides.
|
||||
// Build tool → endpoint mapping from CLI tools and overrides.
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
@@ -199,19 +164,29 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName := range server.CLI.ToolOverrides {
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
// Leaves with serverOverride are routed to a different server's
|
||||
// endpoint (e.g. chat's "search_my_robots" → bot). Registering
|
||||
// them here would overwrite the real owner's tool → endpoint
|
||||
// mapping and send the invocation to the wrong MCP URL.
|
||||
if strings.TrimSpace(override.ServerOverride) != "" {
|
||||
continue
|
||||
}
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shouldUseDirectRuntime gates endpoint resolution to the only invocation
|
||||
// kinds the executor constructs: compat_invocation via
|
||||
// NewCompatibilityInvocation and helper_invocation via NewHelperInvocation.
|
||||
// Every other kind skips direct-runtime resolution and terminates at
|
||||
// handleCatalogMiss in the runner.
|
||||
func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
if strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) != "" {
|
||||
return false
|
||||
}
|
||||
switch invocation.Kind {
|
||||
case "compat_invocation", "helper_invocation":
|
||||
return true
|
||||
@@ -220,22 +195,6 @@ func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// directRuntimeToolEndpoint returns the MCP endpoint owned by the server
|
||||
// whose toolOverrides registered this tool name. Used to correct catalog
|
||||
// lookups when two envelope servers share the same cli.command and the
|
||||
// per-product endpoint map collides (see runner.go cross-check).
|
||||
func directRuntimeToolEndpoint(toolName string) (string, bool) {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
return "", false
|
||||
}
|
||||
dynamicMu.RLock()
|
||||
defer dynamicMu.RUnlock()
|
||||
|
||||
endpoint, ok := dynamicToolEndpoints[toolName]
|
||||
return endpoint, ok && strings.TrimSpace(endpoint) != ""
|
||||
}
|
||||
|
||||
func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
// Priority 0: env-var override always wins (DINGTALK_<PRODUCT>_MCP_URL).
|
||||
normalized := normalizeDirectRuntimeProductID(productID)
|
||||
@@ -377,51 +336,7 @@ func AppendDynamicServer(server mcptypes.ServerDescriptor) {
|
||||
dynamicToolEndpoints = make(map[string]string)
|
||||
}
|
||||
|
||||
if server.CLI.Skip {
|
||||
return
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
dynamicEndpoints[id] = endpoint
|
||||
dynamicProducts[id] = true
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
if _, exists := dynamicEndpoints[cmd]; !exists {
|
||||
dynamicEndpoints[cmd] = endpoint
|
||||
}
|
||||
dynamicProducts[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias != "" && endpoint != "" {
|
||||
dynamicEndpoints[alias] = endpoint
|
||||
dynamicProducts[alias] = true
|
||||
dynamicAliases[alias] = id
|
||||
}
|
||||
}
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
if toolName != "" {
|
||||
dynamicToolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
// Leaves with serverOverride are routed to a different server's
|
||||
// endpoint; skip to avoid overwriting the real owner's mapping.
|
||||
if strings.TrimSpace(override.ServerOverride) != "" {
|
||||
continue
|
||||
}
|
||||
dynamicToolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
registerDynamicServer(server, dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints, true)
|
||||
}
|
||||
|
||||
func normalizeDirectRuntimeProductID(productID string) string {
|
||||
|
||||
+173
-40
@@ -29,9 +29,11 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
@@ -73,6 +75,20 @@ var (
|
||||
// newEventCommand returns the `event` parent command and all its subcommands.
|
||||
// Wired into root.go's utilityCommands list.
|
||||
func newEventCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/event.json
|
||||
// products.event). Catalog assembly stamps provenance contract_final.
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "event",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
|
||||
UseWhen: []string{
|
||||
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"查历史聊天或主动发消息分别用 chat 查询/发送命令",
|
||||
},
|
||||
},
|
||||
})
|
||||
cmd := &cobra.Command{
|
||||
Use: "event",
|
||||
Short: "事件订阅 (DingTalk Stream 长连接)",
|
||||
@@ -353,7 +369,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
f.StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")),
|
||||
"个人 Stream 取票 URL;默认由 MCP base 派生 /stream/connections/ticket")
|
||||
hideEventInternalFlags(cmd, "as")
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
cli.AnnotateRuntimePositionals(cmd, contract.RuntimeSchemaPositional{
|
||||
Name: "event_key",
|
||||
Type: "string",
|
||||
Description: "要消费的一个或多个个人事件码;多个事件必须共享同一目标和过滤上下文",
|
||||
@@ -361,6 +377,46 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
Variadic: true,
|
||||
Index: 0,
|
||||
})
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "consume",
|
||||
CanonicalPath: "event.consume",
|
||||
CLIPath: "event consume",
|
||||
PrimaryCLIPath: "event consume",
|
||||
},
|
||||
Description: "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
UseWhen: []string{
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"用户明确要求监听当前身份的所有单聊或所有群消息",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"需要监听指定群的标题变更、成员进退群或群解散事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
|
||||
"同一目标、同一过滤条件需要同时监听多个兼容事件",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"只查历史聊天记录时用 chat 查询命令",
|
||||
"只看事件目录/字段时用 event list / event schema",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -728,6 +784,36 @@ func newEventListCommand() *cobra.Command {
|
||||
cmd.Flags().BoolVar(&enabledOnly, "enabled-only", false, "个人事件目录只显示 enabled")
|
||||
cmd.Flags().BoolVar(&includePending, "include-pending", false, "个人事件目录包含 pending 项")
|
||||
hideEventInternalFlags(cmd, "as", "all", "all-editions", "client-id")
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "list",
|
||||
CanonicalPath: "event.list",
|
||||
CLIPath: "event list",
|
||||
PrimaryCLIPath: "event list",
|
||||
},
|
||||
Description: "列出支持的个人事件目录与状态说明",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
Reason: "命令读取 CLI 内置的个人事件目录,不绑定 pinned MCP RPC",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出支持的个人事件目录与状态说明",
|
||||
UseWhen: []string{"尚不知道可用 event_key,需要先盘点个人事件目录"},
|
||||
AvoidWhen: []string{
|
||||
"已知 event_key 要看 payload 字段时用 event schema",
|
||||
"要开始监听时用 event consume",
|
||||
},
|
||||
Examples: []string{"dws event list --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -796,6 +882,33 @@ func newEventStatusCommand() *cobra.Command {
|
||||
cmd.Flags().StringVar(&personalOpts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
|
||||
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
|
||||
hideEventInternalFlags(cmd, "as", "all", "all-editions", "client-id", "fail-on-orphan")
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "status",
|
||||
CanonicalPath: "event.status",
|
||||
CLIPath: "event status",
|
||||
PrimaryCLIPath: "event status",
|
||||
},
|
||||
Description: "查看个人事件订阅、本地 bus 与消费进程状态",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite workflow: the command reads the remote personal-event subscription control plane and combines it with local bus and consumer state; no single pinned RPC represents the result.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查看个人事件订阅、本地 bus 与消费进程状态",
|
||||
UseWhen: []string{"需要确认订阅是否活跃、bus/consume 是否仍在运行"},
|
||||
AvoidWhen: []string{"取消订阅用 event stop;列事件目录用 event list"},
|
||||
Examples: []string{"dws event status --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1037,39 +1150,22 @@ func newEventStopCommand() *cobra.Command {
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
// Local validation runs in DeclareLeafMetadata Validate (PreRunE)
|
||||
// before ConfirmSafety. Confirmation is the framework wrap.
|
||||
as, err := eventNormalizeAs(asIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if as == "user" {
|
||||
opts.SubscribeID = firstArg(args)
|
||||
hasSubscribeID := strings.TrimSpace(opts.SubscribeID) != ""
|
||||
if hasSubscribeID && opts.All {
|
||||
return fmt.Errorf("event stop --as user: subscribe_id and --all are mutually exclusive")
|
||||
}
|
||||
if !hasSubscribeID && !opts.All {
|
||||
return fmt.Errorf("event stop --as user: subscribe_id is required unless --all is set")
|
||||
}
|
||||
if eventStopDryRun(c) {
|
||||
return writeEventStopDryRun(c, as, opts)
|
||||
}
|
||||
if !eventStopConfirmed(c) {
|
||||
return eventStopConfirmationRequired("event stop 会取消个人事件订阅并停止本地消费")
|
||||
}
|
||||
return eventRunPersonalStop(c, opts)
|
||||
}
|
||||
if err := rejectChangedFlags(c, "user", "all", "personal-event-base-url", "stream-source-id"); err != nil {
|
||||
return fmt.Errorf("event stop: %w", err)
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("event stop: subscribe_id is only supported with --as user")
|
||||
}
|
||||
if eventStopDryRun(c) {
|
||||
return writeEventStopDryRun(c, as, opts)
|
||||
}
|
||||
if !eventStopConfirmed(c) {
|
||||
return eventStopConfirmationRequired("event stop 会停止事件消费")
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
clientID, _, _, _, err := eventResolveAppCredentials(configDir)
|
||||
if err != nil {
|
||||
@@ -1095,13 +1191,68 @@ func newEventStopCommand() *cobra.Command {
|
||||
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
|
||||
cmd.Flags().BoolVar(&opts.All, "all", false, "取消当前身份下本地记录的所有个人订阅")
|
||||
hideEventInternalFlags(cmd, "as")
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
cli.AnnotateRuntimePositionals(cmd, contract.RuntimeSchemaPositional{
|
||||
Name: "subscribe_id",
|
||||
Type: "string",
|
||||
Description: "要取消的个人事件订阅 ID;与 --all 二选一",
|
||||
Required: false,
|
||||
Index: 0,
|
||||
})
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "destructive", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Validate: func(c *cobra.Command, args []string) error {
|
||||
as, err := eventNormalizeAs(asIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if as == "user" {
|
||||
subscribeID := firstArg(args)
|
||||
hasSubscribeID := strings.TrimSpace(subscribeID) != ""
|
||||
if hasSubscribeID && opts.All {
|
||||
return fmt.Errorf("event stop --as user: subscribe_id and --all are mutually exclusive")
|
||||
}
|
||||
if !hasSubscribeID && !opts.All {
|
||||
return fmt.Errorf("event stop --as user: subscribe_id is required unless --all is set")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := rejectChangedFlags(c, "user", "all", "personal-event-base-url", "stream-source-id"); err != nil {
|
||||
return fmt.Errorf("event stop: %w", err)
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("event stop: subscribe_id is only supported with --as user")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "stop",
|
||||
CanonicalPath: "event.stop",
|
||||
CLIPath: "event stop",
|
||||
PrimaryCLIPath: "event stop",
|
||||
},
|
||||
Description: "取消个人事件订阅并停止对应本地消费",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite workflow: the command deletes remote personal-event subscriptions, interrupts local consumers, updates local state, and may stop the local bus; no single pinned RPC represents the workflow.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "取消个人事件订阅并停止对应本地消费",
|
||||
UseWhen: []string{"用户明确要求取消已知 subscribe_id(或清理全部)并停止消费"},
|
||||
AvoidWhen: []string{
|
||||
"只需检查状态时用 event status",
|
||||
"目标订阅不明确或用户未确认时不要停止",
|
||||
},
|
||||
Examples: []string{"dws event stop SUBSCRIBE_ID --dry-run"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1110,20 +1261,6 @@ func eventStopDryRun(cmd *cobra.Command) bool {
|
||||
return value
|
||||
}
|
||||
|
||||
func eventStopConfirmed(cmd *cobra.Command) bool {
|
||||
value, _ := cmd.Flags().GetBool("yes")
|
||||
return value
|
||||
}
|
||||
|
||||
func eventStopConfirmationRequired(action string) error {
|
||||
return apperrors.NewValidation(
|
||||
action+";请先使用 --dry-run 预览,确认后加 --yes 执行",
|
||||
apperrors.WithReason("confirmation_required"),
|
||||
apperrors.WithHint("先以相同参数加 --dry-run 预览;获得用户确认后改用 --yes 执行"),
|
||||
apperrors.WithActions("使用 --dry-run 生成预览", "获得用户确认后使用 --yes 执行"),
|
||||
)
|
||||
}
|
||||
|
||||
func writeEventStopDryRun(cmd *cobra.Command, identity string, opts personalStopOptions) error {
|
||||
payload := map[string]any{
|
||||
"dry_run": true,
|
||||
@@ -1274,7 +1411,3 @@ func eventTypesWithDefault(types []string) []string {
|
||||
}
|
||||
return registry.CatchAllEventTypes()
|
||||
}
|
||||
|
||||
// compile-time guard: avoid "imported and not used" if any of these
|
||||
// indirect imports become unused after future refactors.
|
||||
var _ = io.Discard
|
||||
|
||||
@@ -29,8 +29,11 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
@@ -170,13 +173,43 @@ func newEventSchemaCommand() *cobra.Command {
|
||||
cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json")
|
||||
cmd.Flags().BoolVar(&flatten, "flatten", false, "显示 --flatten 消费模式对应的顶层业务字段 schema")
|
||||
hideEventInternalFlags(cmd, "as")
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
cli.AnnotateRuntimePositionals(cmd, contract.RuntimeSchemaPositional{
|
||||
Name: "event_key",
|
||||
Type: "string",
|
||||
Description: "要查询 payload 字段定义的个人事件码",
|
||||
Required: true,
|
||||
Index: 0,
|
||||
})
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "schema",
|
||||
CanonicalPath: "event.schema",
|
||||
CLIPath: "event schema",
|
||||
PrimaryCLIPath: "event schema",
|
||||
},
|
||||
Description: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
Reason: "命令读取 CLI 内置的个人事件 payload 定义,不绑定 pinned MCP RPC",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
|
||||
UseWhen: []string{"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"},
|
||||
AvoidWhen: []string{
|
||||
"查询 CLI 命令参数契约时用顶层 dws schema",
|
||||
"要实际收事件时用 event consume",
|
||||
},
|
||||
Examples: []string{"dws event schema user_im_message_receive_at --flatten --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestEventStopDryRunPrecedesConfirmationAndReturnsPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopDryRunDoesNotBypassTargetValidation(t *testing.T) {
|
||||
func TestCrossPlatformCoverageEventStopDryRunDoesNotBypassTargetValidation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// This file holds deliberate no-op stubs that act as edition-sync anchors for
|
||||
// the private wukong edition overlay. The overlay replaces these function
|
||||
// bodies with real host-compatibility configuration while the open-source
|
||||
// build keeps them empty; both editions therefore share identical call sites
|
||||
// (access_token_resolve.go, auth_command.go, doctor_command.go,
|
||||
// force_refresh.go). These stubs are sync seams for the edition overlay, not
|
||||
// dead code: do not delete them or their call sites during cleanup.
|
||||
package app
|
||||
|
||||
import (
|
||||
|
||||
@@ -82,10 +82,6 @@ func injectStaticServers() {
|
||||
SetDynamicServers(descriptors)
|
||||
}
|
||||
|
||||
func newLegacyHiddenCommands(_ executor.Runner) []*cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeTopLevelCommands(commands []*cobra.Command) []*cobra.Command {
|
||||
byName := make(map[string]*cobra.Command, len(commands))
|
||||
for _, cmd := range commands {
|
||||
|
||||
@@ -18,7 +18,10 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -74,13 +77,43 @@ func newMCPURLGetCommand(caller edition.ToolCaller) *cobra.Command {
|
||||
return writeMCPURLResult(cmd, result)
|
||||
},
|
||||
}
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
cli.AnnotateRuntimePositionals(cmd, contract.RuntimeSchemaPositional{
|
||||
Name: "mcp_id",
|
||||
Type: "string",
|
||||
Description: "钉钉 MCP 市场中的 mcpId",
|
||||
Required: true,
|
||||
Index: 0,
|
||||
})
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mcp",
|
||||
Name: "url_get",
|
||||
CanonicalPath: "mcp.url_get",
|
||||
CLIPath: "mcp url get",
|
||||
PrimaryCLIPath: "mcp url get",
|
||||
},
|
||||
Description: "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
|
||||
UseWhen: []string{"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"},
|
||||
AvoidWhen: []string{
|
||||
"只是查询 DWS 已公开命令或参数时使用 dws schema",
|
||||
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播",
|
||||
},
|
||||
Examples: []string{"dws mcp url get 10043 --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
|
||||
// Actually install with the filtered+mandatory set and assert dws-shared landed.
|
||||
dest := t.TempDir()
|
||||
var out, errOut bytes.Buffer
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut, true); err != nil {
|
||||
t.Fatalf("install: %v (%s)", err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
|
||||
|
||||
@@ -132,7 +132,7 @@ func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context
|
||||
}()
|
||||
rejectRunner := ¶mAliasDryRunRejectRunner{}
|
||||
originalRunnerFactory := rootNewCommandRunnerWithFlags
|
||||
rootNewCommandRunnerWithFlags = func(cli.CatalogLoader, *GlobalFlags) executor.Runner {
|
||||
rootNewCommandRunnerWithFlags = func(*GlobalFlags) executor.Runner {
|
||||
return rejectRunner
|
||||
}
|
||||
root := NewRootCommand()
|
||||
@@ -172,7 +172,7 @@ func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ..
|
||||
defer func() { os.Args = originalArgs }()
|
||||
|
||||
originalRunnerFactory := rootNewCommandRunnerWithFlags
|
||||
rootNewCommandRunnerWithFlags = func(cli.CatalogLoader, *GlobalFlags) executor.Runner {
|
||||
rootNewCommandRunnerWithFlags = func(*GlobalFlags) executor.Runner {
|
||||
return ¶mAliasCaptureRunner{caller: caller}
|
||||
}
|
||||
root := NewRootCommand()
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -417,10 +418,8 @@ func TestConflictingPluginDescriptorCannotReplaceDistributionEndpoint(t *testing
|
||||
|
||||
func TestSchemaSourceRootDoesNotLoadRuntimePlugins(t *testing.T) {
|
||||
isolatePluginRuntime(t)
|
||||
previous := rootLoadPlugins
|
||||
t.Cleanup(func() { rootLoadPlugins = previous })
|
||||
var calls atomic.Int32
|
||||
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
|
||||
testseam.Swap(t, &rootLoadPlugins, func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
|
||||
calls.Add(1)
|
||||
AppendDynamicServer(conferencePluginDescriptor())
|
||||
return buildPluginCommands(
|
||||
@@ -428,7 +427,7 @@ func TestSchemaSourceRootDoesNotLoadRuntimePlugins(t *testing.T) {
|
||||
executor.EchoRunner{},
|
||||
nil,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
base := NewSchemaSourceRootCommand()
|
||||
if calls.Load() != 0 {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/userdef"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -27,14 +28,11 @@ import (
|
||||
type schemaSourceContextKey struct{}
|
||||
|
||||
func TestSchemaSourceRootPropagatesContextWithoutLoadingPlugins(t *testing.T) {
|
||||
previous := rootLoadPlugins
|
||||
t.Cleanup(func() { rootLoadPlugins = previous })
|
||||
|
||||
pluginLoads := 0
|
||||
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
|
||||
testseam.Swap(t, &rootLoadPlugins, func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
|
||||
pluginLoads++
|
||||
return nil
|
||||
}
|
||||
})
|
||||
wantContext := context.WithValue(context.Background(), schemaSourceContextKey{}, "schema")
|
||||
root := NewSchemaSourceRootCommand(wantContext)
|
||||
if root.Context() != wantContext {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
@@ -23,7 +22,7 @@ var (
|
||||
recoverySaveAnalysis = (*recovery.Store).SaveAnalysis
|
||||
)
|
||||
|
||||
func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *GlobalFlags) *cobra.Command {
|
||||
func newRecoveryCommand(flags *GlobalFlags) *cobra.Command {
|
||||
var (
|
||||
planUseLast bool
|
||||
planEventID string
|
||||
@@ -34,7 +33,7 @@ func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *Glob
|
||||
executionFile string
|
||||
)
|
||||
|
||||
runtime := newRecoveryRuntime(loader, flags)
|
||||
runtime := newRecoveryRuntime(flags)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "recovery",
|
||||
@@ -259,12 +258,11 @@ func legacyRecoveryAttempts(count int, actions []string, result, errorSummary st
|
||||
}
|
||||
|
||||
type recoveryRuntime struct {
|
||||
loader cli.CatalogLoader
|
||||
transport *transport.Client
|
||||
flags *GlobalFlags
|
||||
}
|
||||
|
||||
func newRecoveryRuntime(loader cli.CatalogLoader, flags *GlobalFlags) *recoveryRuntime {
|
||||
func newRecoveryRuntime(flags *GlobalFlags) *recoveryRuntime {
|
||||
var httpClient *http.Client
|
||||
if flags != nil && flags.Timeout > 0 {
|
||||
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
|
||||
@@ -272,7 +270,6 @@ func newRecoveryRuntime(loader cli.CatalogLoader, flags *GlobalFlags) *recoveryR
|
||||
client := transport.NewClient(httpClient)
|
||||
client.ExtraHeaders = resolveIdentityHeaders()
|
||||
return &recoveryRuntime{
|
||||
loader: loader,
|
||||
transport: client,
|
||||
flags: flags,
|
||||
}
|
||||
@@ -353,22 +350,11 @@ func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (r *recoveryRuntime) resolveEndpoint(ctx context.Context, productID, toolName string) (string, error) {
|
||||
func (r *recoveryRuntime) resolveEndpoint(_ context.Context, productID, toolName string) (string, error) {
|
||||
if endpoint, ok := directRuntimeEndpoint(productID, toolName); ok {
|
||||
return endpoint, nil
|
||||
}
|
||||
if r == nil || r.loader == nil {
|
||||
return "", fmt.Errorf("未找到服务 %s 的 endpoint", productID)
|
||||
}
|
||||
catalog, err := r.loader.Load(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
product, ok := catalog.FindProduct(productID)
|
||||
if !ok || strings.TrimSpace(product.Endpoint) == "" {
|
||||
return "", fmt.Errorf("未找到服务 %s 的 endpoint", productID)
|
||||
}
|
||||
return strings.TrimSpace(product.Endpoint), nil
|
||||
return "", endpointNotResolvedError(productID, toolName, "no dynamic endpoint registered for product or tool")
|
||||
}
|
||||
|
||||
func recoveryRuntimeToken(flags *GlobalFlags) string {
|
||||
|
||||
@@ -9,13 +9,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func recoveryCoverageRun(cmdArgs ...string) (string, error) {
|
||||
cmd := newRecoveryCommand(context.Background(), cli.StaticLoader{}, &GlobalFlags{})
|
||||
cmd := newRecoveryCommand(&GlobalFlags{})
|
||||
out := &strings.Builder{}
|
||||
cmd.SetOut(out)
|
||||
cmd.SetErr(io.Discard)
|
||||
@@ -49,7 +48,7 @@ func TestCrossPlatformCoverageRecoveryCommandRemainingCoverage(t *testing.T) {
|
||||
}
|
||||
recoverySaveAnalysis = oldSaveAnalysis
|
||||
|
||||
parent := newRecoveryCommand(context.Background(), nil, nil)
|
||||
parent := newRecoveryCommand(nil)
|
||||
parent.SetOut(io.Discard)
|
||||
if err := parent.RunE(parent, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -127,13 +126,12 @@ func TestCrossPlatformCoverageRecoveryExecutionAndRuntimeRemainingCoverage(t *te
|
||||
t.Fatal("invalid attempt array should fail")
|
||||
}
|
||||
|
||||
fail := errors.New("catalog")
|
||||
SetDynamicServers(nil)
|
||||
runtime := &recoveryRuntime{
|
||||
loader: cli.CatalogLoaderFrom(cli.Catalog{}, fail),
|
||||
transport: transport.NewClient(nil),
|
||||
flags: &GlobalFlags{Token: "token"},
|
||||
}
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); !errors.Is(err, fail) {
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
|
||||
t.Fatalf("direct resolution error = %v", err)
|
||||
}
|
||||
if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" {
|
||||
|
||||
+42
-57
@@ -369,6 +369,7 @@ func commandRequestsJSONErrors(cmd *cobra.Command) bool {
|
||||
// is propagated to background goroutines and the Cobra command tree so
|
||||
// that SIGINT/SIGTERM can cancel in-flight work.
|
||||
func NewRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
registerSchemaRuntimeDelivery()
|
||||
var rootCtx context.Context
|
||||
if len(ctx) > 0 && ctx[0] != nil {
|
||||
rootCtx = ctx[0]
|
||||
@@ -377,8 +378,9 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
}
|
||||
|
||||
// NewSchemaSourceRootCommand constructs the distribution-owned command tree
|
||||
// used by Schema generation and command-surface policy. Installed plugins and
|
||||
// user-defined shortcuts must not change the reviewed embedded Schema.
|
||||
// used as the Schema assembly source root (RegisterSchemaSourceRoot →
|
||||
// ResolveSchemaBuild) and by command-surface policy. Installed plugins and
|
||||
// user-defined shortcuts must not change the reviewed Schema surface.
|
||||
func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
var rootCtx context.Context
|
||||
if len(ctx) > 0 && ctx[0] != nil {
|
||||
@@ -391,6 +393,7 @@ func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
|
||||
// optional pipeline engine for input correction. When engine is nil,
|
||||
// no pipeline processing is applied.
|
||||
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
|
||||
registerSchemaRuntimeDelivery()
|
||||
return newRootCommandWithEngine(rootCtx, engine, true)
|
||||
}
|
||||
|
||||
@@ -400,10 +403,7 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
}
|
||||
flags := &GlobalFlags{}
|
||||
authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:]))
|
||||
loader := cli.EnvironmentLoader{
|
||||
LookupEnv: os.LookupEnv,
|
||||
}
|
||||
runner := rootNewCommandRunnerWithFlags(loader, flags)
|
||||
runner := rootNewCommandRunnerWithFlags(flags)
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
@@ -458,13 +458,8 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
|
||||
bindPersistentFlags(root, flags)
|
||||
|
||||
schemaCmd := newSchemaCommand(loader)
|
||||
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
|
||||
// The legacy dynamic MCP surface remains disabled, but reviewed static MCP
|
||||
// helpers registered below are part of the public CLI and Schema surface.
|
||||
mcpCmd.Hidden = false
|
||||
mcpCmd.Short = "管理 MCP 服务连接信息"
|
||||
mcpCmd.Long = "管理经过审核并纳入 Schema 的 MCP 服务连接辅助能力。"
|
||||
schemaCmd := cli.NewSchemaCommand()
|
||||
mcpCmd := cli.NewMCPCommand()
|
||||
// Wrap the caller so every MCP tool call's shape is recorded to the local
|
||||
// usage log (privacy-preserving; see internal/shortcut/usage). Powers
|
||||
// `dws shortcut stats` and future high-frequency shortcut distillation.
|
||||
@@ -477,13 +472,13 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
newAPICommand(flags),
|
||||
newSkillCommand(),
|
||||
newCacheCommand(),
|
||||
newCatalogCommand(loader),
|
||||
newCatalogCommand(),
|
||||
newConfigCommand(),
|
||||
newDoctorCommand(),
|
||||
newEventCommand(),
|
||||
newAuditCommand(),
|
||||
newCompletionCommand(root),
|
||||
newRecoveryCommand(rootCtx, loader, flags),
|
||||
newRecoveryCommand(flags),
|
||||
newUpgradeCommand(),
|
||||
newVersionCommand(),
|
||||
newPluginCommand(),
|
||||
@@ -494,7 +489,6 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
root.AddCommand(utilityCommands...)
|
||||
|
||||
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
|
||||
root.AddCommand(newLegacyHiddenCommands(runner)...)
|
||||
|
||||
// PAT authorization commands (open-source core)
|
||||
pat.RegisterCommands(root, patCaller)
|
||||
@@ -692,18 +686,6 @@ func newVersionCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func newSchemaCommand(loader cli.CatalogLoader) *cobra.Command {
|
||||
return cli.NewSchemaCommand(loader)
|
||||
}
|
||||
|
||||
// buildMCPCommandFn is a test seam for newMCPCommand.
|
||||
var buildMCPCommandFn = cli.NewMCPCommand
|
||||
|
||||
// newMCPCommand builds the `dws mcp` command tree.
|
||||
func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
|
||||
return buildMCPCommandFn(ctx, loader, runner, engine)
|
||||
}
|
||||
|
||||
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
|
||||
// unless they correspond to a static endpoint product or an edition-visible
|
||||
// compatibility command.
|
||||
@@ -711,27 +693,6 @@ func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executo
|
||||
// stay hidden.
|
||||
func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
allowedProducts := resolveVisibleProducts()
|
||||
staticCommands := map[string]bool{
|
||||
"auth": true,
|
||||
"api": true,
|
||||
"audit": true,
|
||||
"cache": true,
|
||||
"config": true,
|
||||
"dev": true,
|
||||
"doctor": true,
|
||||
"event": true,
|
||||
"completion": true,
|
||||
"skill": true,
|
||||
"plugin": true,
|
||||
"profile": true,
|
||||
"version": true,
|
||||
"help": true,
|
||||
"markdown": true,
|
||||
"recovery": true,
|
||||
"schema": true,
|
||||
"mcp": true,
|
||||
"upgrade": true,
|
||||
}
|
||||
for _, cmd := range root.Commands() {
|
||||
name := cmd.Name()
|
||||
if cmd.Hidden {
|
||||
@@ -747,16 +708,40 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
// builtinCommandNames is the shared base set of built-in command names. Both
|
||||
// staticCommands (the visibility allow-list used by
|
||||
// hideNonDirectRuntimeCommands) and reservedCommands (the plugin-override
|
||||
// blocklist) derive from this single set so they cannot drift apart.
|
||||
var builtinCommandNames = map[string]bool{
|
||||
"auth": true, "api": true, "audit": true, "cache": true, "config": true,
|
||||
"doctor": true, "event": true, "completion": true, "skill": true,
|
||||
"plugin": true, "profile": true, "version": true, "help": true,
|
||||
"recovery": true, "schema": true, "mcp": true, "upgrade": true,
|
||||
}
|
||||
|
||||
// commandNameSet returns a new set containing every name in base plus extras.
|
||||
func commandNameSet(base map[string]bool, extras ...string) map[string]bool {
|
||||
set := make(map[string]bool, len(base)+len(extras))
|
||||
for name := range base {
|
||||
set[name] = true
|
||||
}
|
||||
for _, extra := range extras {
|
||||
set[extra] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// staticCommands is the set of built-in commands that stay visible even when
|
||||
// they are not backed by a static endpoint product. Asymmetry with
|
||||
// reservedCommands is intentional: dev/markdown stay visible but are not
|
||||
// plugin-reserved, while login/logout are plugin-reserved but are not static
|
||||
// top-level commands.
|
||||
var staticCommands = commandNameSet(builtinCommandNames, "dev", "markdown")
|
||||
|
||||
// reservedCommands is the set of built-in command names that plugins must
|
||||
// not override. This protects core CLI functionality from being hijacked
|
||||
// by a malicious or misconfigured plugin.
|
||||
var reservedCommands = map[string]bool{
|
||||
"auth": true, "api": true, "audit": true, "login": true, "logout": true,
|
||||
"plugin": true, "profile": true, "skill": true, "cache": true,
|
||||
"config": true, "doctor": true, "event": true, "completion": true,
|
||||
"recovery": true, "upgrade": true, "version": true,
|
||||
"schema": true, "mcp": true, "help": true,
|
||||
}
|
||||
var reservedCommands = commandNameSet(builtinCommandNames, "login", "logout")
|
||||
|
||||
var replaceablePluginFallbacks = map[string]bool{
|
||||
"conference": true,
|
||||
@@ -1332,7 +1317,7 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
|
||||
// Register → PreParse → PostParse → PreRequest → PostResponse.
|
||||
//
|
||||
// Phases are invoked at their respective integration points:
|
||||
// - Register: during command tree construction (newMCPCommand)
|
||||
// - Register: during command tree construction (cli.NewMCPCommand)
|
||||
// - PreParse: before Cobra parses raw argv (RunPreParse)
|
||||
// - PostParse: after Cobra parsing, before validation (canonical RunE)
|
||||
// - PreRequest: after validation, before JSON-RPC dispatch (canonical RunE)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
// BenchmarkNewRootCommand measures building the real Cobra tree — roughly 800
|
||||
// leaves with their flags, Schema annotations and PostMount work.
|
||||
//
|
||||
// It is the other half of the cold-start attribution: `dws version` never
|
||||
// decodes the Schema catalog, so whatever it spends beyond process start is
|
||||
// mostly this. Without splitting the two, an optimization aimed at JSON parsing
|
||||
// could target the smaller cost.
|
||||
func BenchmarkNewRootCommand(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
root := NewRootCommand()
|
||||
if root == nil {
|
||||
b.Fatal("nil root")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -161,9 +162,7 @@ func TestRootChatMediaUploadWithoutAppCredentialsReturnsMigrationValidation(t *t
|
||||
"--file", filePath,
|
||||
"--type", "image",
|
||||
}
|
||||
previousArgs := os.Args
|
||||
os.Args = append([]string{"dws"}, commandArgs...)
|
||||
t.Cleanup(func() { os.Args = previousArgs })
|
||||
testseam.Swap(t, &os.Args, append([]string{"dws"}, commandArgs...))
|
||||
|
||||
root := NewRootCommand()
|
||||
var output bytes.Buffer
|
||||
|
||||
+45
-94
@@ -30,7 +30,6 @@ import (
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
@@ -123,7 +122,7 @@ func logHostOwnedPATDecisionOnce() {
|
||||
})
|
||||
}
|
||||
|
||||
func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) executor.Runner {
|
||||
func newCommandRunnerWithFlags(flags *GlobalFlags) executor.Runner {
|
||||
// Ensure DWS_CLIENT_ID env is populated from persisted config before
|
||||
// resolveIdentityHeaders reads it. This covers fresh-process cold starts
|
||||
// where no env var has been inherited from a parent process.
|
||||
@@ -141,10 +140,8 @@ func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) exe
|
||||
transportClient.ExtraHeaders = resolveIdentityHeaders()
|
||||
transportClient.FileLogger = FileLoggerInstance()
|
||||
return &runtimeRunner{
|
||||
loader: loader,
|
||||
transport: transportClient,
|
||||
globalFlags: flags,
|
||||
fallback: executor.EchoRunner{},
|
||||
scanner: newRuntimeContentScanner(),
|
||||
enforceContentScan: runtimeFlagEnabled(os.Getenv(runtimeContentScanEnforceEnv), false),
|
||||
includeScanReport: runtimeFlagEnabled(os.Getenv(runtimeContentScanReportOutputEnv), false),
|
||||
@@ -152,7 +149,6 @@ func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) exe
|
||||
}
|
||||
|
||||
type runtimeRunner struct {
|
||||
loader cli.CatalogLoader
|
||||
transport *transport.Client
|
||||
globalFlags *GlobalFlags
|
||||
fallback executor.Runner
|
||||
@@ -204,20 +200,14 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
return r.runMultiProfile(ctx, invocation, selections)
|
||||
}
|
||||
if strings.TrimSpace(rawProfile) != "" {
|
||||
profile, err := authpkg.ResolveProfile(defaultConfigDir(), rawProfile)
|
||||
profile, err := runnerResolveProfile(defaultConfigDir(), rawProfile)
|
||||
if err != nil {
|
||||
return executor.Result{}, apperrors.NewValidation(err.Error())
|
||||
}
|
||||
if profile == nil {
|
||||
return executor.Result{}, apperrors.NewValidation(fmt.Sprintf("profile %q not found", rawProfile))
|
||||
}
|
||||
resolvedSelector := authpkg.ProfileSelector(*profile)
|
||||
if strings.TrimSpace(profile.UserID) == "" {
|
||||
// Preserve a unique local-name selector for an unresolved account.
|
||||
// Reducing it to corpId can select a different exact account through
|
||||
// the organization's current-account pointer.
|
||||
resolvedSelector = rawProfile
|
||||
}
|
||||
resolvedSelector := profileRuntimeSelector(*profile, rawProfile)
|
||||
authpkg.SetRuntimeProfile(resolvedSelector)
|
||||
defer authpkg.SetRuntimeProfile(rawProfile)
|
||||
}
|
||||
@@ -244,12 +234,12 @@ func (r *runtimeRunner) RunReadOnly(ctx context.Context, invocation executor.Inv
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) {
|
||||
if r.loader == nil || r.transport == nil {
|
||||
if r.transport == nil {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
r.transport.ExtraHeaders = resolveIdentityHeaders()
|
||||
|
||||
// Mock mode: skip catalog validation, use a placeholder endpoint.
|
||||
// Mock mode: skip endpoint resolution, use a placeholder endpoint.
|
||||
if r.globalFlags != nil && r.globalFlags.Mock {
|
||||
endpoint := fmt.Sprintf("https://mock-mcp-%s.dingtalk.com", invocation.CanonicalProduct)
|
||||
if override, ok := productEndpointOverride(invocation.CanonicalProduct); ok {
|
||||
@@ -260,7 +250,7 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc
|
||||
|
||||
// Prefetch the Keychain token in the background. Keychain access costs
|
||||
// ~70ms on macOS; starting it here lets the load overlap with endpoint
|
||||
// resolution and catalog loading below.
|
||||
// resolution below.
|
||||
if prefetchToken {
|
||||
go func() {
|
||||
_, _ = runnerGetCachedRuntimeToken(ctx)
|
||||
@@ -273,56 +263,21 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc
|
||||
}
|
||||
}
|
||||
|
||||
catalogStart := time.Now()
|
||||
catalog, err := r.loader.Load(ctx)
|
||||
RecordTiming(ctx, "catalog_load", time.Since(catalogStart))
|
||||
if err != nil {
|
||||
var degraded *cli.CatalogDegraded
|
||||
if !errors.As(err, °raded) {
|
||||
return executor.Result{}, err
|
||||
}
|
||||
}
|
||||
// Discovery is retired: endpoint resolution is the dynamic server
|
||||
// registry only, so a direct-runtime miss is terminal.
|
||||
return r.handleCatalogMiss(ctx, invocation, "no dynamic endpoint registered for product or tool")
|
||||
}
|
||||
|
||||
product, ok := catalog.FindProduct(invocation.CanonicalProduct)
|
||||
if !ok || strings.TrimSpace(product.Endpoint) == "" {
|
||||
return r.handleCatalogMiss(ctx, invocation, "product missing from discovery catalog and no supplement/env override")
|
||||
// profileRuntimeSelector resolves the runtime profile selector for an
|
||||
// invocation. It preserves a unique local-name selector for an unresolved
|
||||
// account (empty UserID): reducing it to corpId can select a different exact
|
||||
// account through the organization's current-account pointer. Shared by the
|
||||
// single-profile path in Run and the multi-profile path in runMultiProfile.
|
||||
func profileRuntimeSelector(profile authpkg.Profile, rawSelector string) string {
|
||||
if strings.TrimSpace(profile.UserID) == "" {
|
||||
return rawSelector
|
||||
}
|
||||
if _, ok := product.FindTool(invocation.Tool); !ok {
|
||||
// Catalog knows the product but not the tool — this happens when the
|
||||
// catalog entry came from SupplementServers (endpoint-only, no tool
|
||||
// list). Trust directRuntimeEndpoint to re-resolve a working endpoint
|
||||
// for the tool. If that also misses, fall through to handleCatalogMiss
|
||||
// so stderr still carries the explicit not-resolved signal.
|
||||
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
|
||||
if r.globalFlags != nil && r.globalFlags.DryRun {
|
||||
invocation.DryRun = true
|
||||
}
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
return r.handleCatalogMiss(ctx, invocation, fmt.Sprintf("tool %q not declared by product %q in discovery catalog", invocation.Tool, invocation.CanonicalProduct))
|
||||
}
|
||||
if r.globalFlags != nil && r.globalFlags.DryRun {
|
||||
invocation.DryRun = true
|
||||
}
|
||||
|
||||
endpoint := product.Endpoint
|
||||
if override, ok := productEndpointOverride(invocation.CanonicalProduct); ok {
|
||||
endpoint = override
|
||||
}
|
||||
// Multi-server tool-name authority correction.
|
||||
//
|
||||
// When two envelope servers share the same cli.command (e.g. group-chat
|
||||
// and im both publish `dws chat ...`), the endpoints[cmd] map in
|
||||
// registerDynamicServer is the second-writer wins, and catalog FindProduct
|
||||
// may pick the wrong product's Endpoint for a tool whose real owner is
|
||||
// a different server. Cross-check the canonical tool→endpoint map: when
|
||||
// the per-tool endpoint exists and differs from the per-product endpoint
|
||||
// catalog returned, trust the tool-owner endpoint (the server that
|
||||
// actually declares this tool in its toolOverrides).
|
||||
if toolEndpoint, ok := directRuntimeToolEndpoint(invocation.Tool); ok && toolEndpoint != "" && toolEndpoint != endpoint {
|
||||
endpoint = toolEndpoint
|
||||
}
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
return authpkg.ProfileSelector(profile)
|
||||
}
|
||||
|
||||
type multiProfileSelection struct {
|
||||
@@ -376,10 +331,7 @@ func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor
|
||||
failed := 0
|
||||
|
||||
for _, selection := range selections {
|
||||
resolvedSelector := authpkg.ProfileSelector(selection.Profile)
|
||||
if strings.TrimSpace(selection.Profile.UserID) == "" {
|
||||
resolvedSelector = selection.Selector
|
||||
}
|
||||
resolvedSelector := profileRuntimeSelector(selection.Profile, selection.Selector)
|
||||
authpkg.SetRuntimeProfile(resolvedSelector)
|
||||
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
|
||||
|
||||
@@ -468,45 +420,51 @@ func multiProfileErrorPayload(err error) map[string]any {
|
||||
return payload
|
||||
}
|
||||
|
||||
// handleCatalogMiss decides what to do when discovery catalog does not cover the
|
||||
// requested product / tool and no `directRuntimeEndpoint` match fired earlier.
|
||||
// handleCatalogMiss decides what to do when the dynamic server registry has
|
||||
// no endpoint for the requested product / tool and no `directRuntimeEndpoint`
|
||||
// match fired earlier. The discovery catalog is retired; endpoint resolution
|
||||
// is the dynamic server registry only, so a registry miss here is terminal.
|
||||
//
|
||||
// Previously every catalog miss silently fell through to EchoRunner, which
|
||||
// Previously every miss silently fell through to EchoRunner, which
|
||||
// returns an empty `executor.Result{Response: nil}`. The helper-invocation
|
||||
// adapter then converted that into `&edition.ToolResult{}`, whose `Content`
|
||||
// marshals to `null`, surfacing as `{"Content": null}` at the CLI. Users had no
|
||||
// signal that endpoint resolution failed — see the fix-wukong-discovery-missing-servers plan (Phase 3) for the full trace.
|
||||
//
|
||||
// New contract:
|
||||
// - Dry-run (invocation.DryRun or globalFlags.DryRun): keep EchoRunner so
|
||||
// `--dry-run` still prints the planned payload without real execution.
|
||||
// - Otherwise: return an explicit apperrors.NewAPI("endpoint_not_resolved")
|
||||
// with the offending product/tool attached. This fails fast to stderr and
|
||||
// makes missing envelopes / supplement gaps immediately visible.
|
||||
func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation executor.Invocation, detail string) (executor.Result, error) {
|
||||
dryRun := invocation.DryRun || (r.globalFlags != nil && r.globalFlags.DryRun)
|
||||
if dryRun {
|
||||
invocation.DryRun = true
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
// Contract: return an explicit apperrors.NewAPI("endpoint_not_resolved")
|
||||
// with the offending product/tool attached. This fails fast to stderr and
|
||||
// makes missing envelopes / supplement gaps immediately visible. Dry-run
|
||||
// invocations never reach this path in production: Run enforces the dry-run
|
||||
// barrier before endpoint resolution, and runSingle is only re-entered via
|
||||
// Run (multi-profile and PAT retry both route back through it).
|
||||
func (r *runtimeRunner) handleCatalogMiss(_ context.Context, invocation executor.Invocation, detail string) (executor.Result, error) {
|
||||
return executor.Result{}, endpointNotResolvedError(invocation.CanonicalProduct, invocation.Tool, detail)
|
||||
}
|
||||
|
||||
// endpointNotResolvedError builds the shared terminal error for a dynamic
|
||||
// server registry miss. Both the runtime runner (handleCatalogMiss) and the
|
||||
// recovery runtime (resolveEndpoint) use it so endpoint misses classify
|
||||
// identically: CategoryAPI, operation "discovery.resolve", reason
|
||||
// "endpoint_not_resolved", with the product ID as the server key.
|
||||
func endpointNotResolvedError(productID, toolName, detail string) error {
|
||||
hint := "当前命令已注册,但静态端点目录中缺少对应 product/server endpoint。这通常是服务发现下线后的同步产物缺口,不是参数错误;请不要通过反复调整 flag 重试。"
|
||||
actions := []string{
|
||||
"确认 internal/syncdata.StaticServers() 是否包含该 product/server",
|
||||
"运行 sync-oss 重新生成静态端点与路由",
|
||||
"若该能力已下线,请在 skill 与 --help 中标记 unavailable 并提供替代命令",
|
||||
}
|
||||
if strings.TrimSpace(invocation.CanonicalProduct) == devappProductID {
|
||||
if strings.TrimSpace(productID) == devappProductID {
|
||||
hint = "dev app(product id: devapp)是 helper-only 产品,命令树不依赖服务发现;真实调用需要通过 StaticServers/SupplementServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。"
|
||||
actions = []string{
|
||||
"检查 StaticServers/SupplementServers 是否包含 devapp endpoint",
|
||||
"本地调试可临时设置 DINGTALK_DEVAPP_MCP_URL 后重试",
|
||||
}
|
||||
}
|
||||
return executor.Result{}, apperrors.NewAPI(
|
||||
fmt.Sprintf("endpoint not resolved for product %q (tool %q): %s", invocation.CanonicalProduct, invocation.Tool, detail),
|
||||
return apperrors.NewAPI(
|
||||
fmt.Sprintf("endpoint not resolved for product %q (tool %q): %s", productID, toolName, detail),
|
||||
apperrors.WithOperation("discovery.resolve"),
|
||||
apperrors.WithReason("endpoint_not_resolved"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithServerKey(productID),
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithActions(actions...),
|
||||
)
|
||||
@@ -792,13 +750,6 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
return executor.Result{Invocation: invocation, Response: response}, nil
|
||||
}
|
||||
|
||||
// executeStdioInvocation dispatches a tool call through a local StdioClient
|
||||
// subprocess instead of the HTTP transport. This is used for plugin stdio
|
||||
// servers whose endpoints use the stdio:// scheme.
|
||||
func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
return r.executeStdioInvocationAtEndpoint(ctx, "", invocation)
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
|
||||
ctx context.Context,
|
||||
endpoint string,
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
@@ -35,7 +34,7 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
|
||||
runnerGetCachedRuntimeToken = oldCachedToken
|
||||
})
|
||||
|
||||
created := newCommandRunnerWithFlags(cli.StaticLoader{}, &GlobalFlags{Timeout: 2})
|
||||
created := newCommandRunnerWithFlags(&GlobalFlags{Timeout: 2})
|
||||
if created.(*runtimeRunner).transport == nil {
|
||||
t.Fatal("runner transport was not created")
|
||||
}
|
||||
@@ -55,21 +54,20 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
|
||||
return "", nil
|
||||
}
|
||||
r := &runtimeRunner{
|
||||
loader: cli.CatalogLoaderFrom(cli.Catalog{}, wantErr),
|
||||
transport: transport.NewClient(nil),
|
||||
fallback: runnerCoverageFallback{},
|
||||
}
|
||||
directMiss := inv
|
||||
directMiss.Kind = "helper_invocation"
|
||||
if _, err := r.runSingle(context.Background(), directMiss, false); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("direct runtime miss load error = %v", err)
|
||||
if _, err := r.runSingle(context.Background(), directMiss, false); err == nil || !strings.Contains(err.Error(), "no dynamic endpoint registered for product or tool") {
|
||||
t.Fatalf("direct runtime miss = %v", err)
|
||||
}
|
||||
directHit := executor.Invocation{Kind: "helper_invocation", CanonicalProduct: defaultPATProductID, Tool: "pat", DryRun: true}
|
||||
if got, err := r.runSingle(context.Background(), directHit, false); err != nil || got.Response["dry_run"] != true {
|
||||
t.Fatalf("direct runtime hit = %#v, %v", got, err)
|
||||
}
|
||||
if _, err := r.runSingle(context.Background(), inv, true); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("runSingle error = %v", err)
|
||||
if _, err := r.runSingle(context.Background(), inv, true); err == nil {
|
||||
t.Fatal("endpoint miss succeeded")
|
||||
}
|
||||
<-prefetched
|
||||
|
||||
@@ -113,17 +111,18 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
|
||||
t.Fatalf("multi success aggregation = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
product := cli.CanonicalProduct{ID: "product", Endpoint: "https://catalog.test", Tools: []cli.ToolDescriptor{{RPCName: "tool"}}}
|
||||
r = &runtimeRunner{
|
||||
loader: cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}},
|
||||
transport: transport.NewClient(nil),
|
||||
globalFlags: &GlobalFlags{DryRun: true},
|
||||
fallback: runnerCoverageFallback{},
|
||||
}
|
||||
overrideInv := inv
|
||||
overrideInv.Kind = "helper_invocation"
|
||||
overrideInv.DryRun = true
|
||||
t.Setenv("DINGTALK_PRODUCT_MCP_URL", "https://override.test")
|
||||
got, err := r.runSingle(context.Background(), inv, false)
|
||||
got, err := r.runSingle(context.Background(), overrideInv, false)
|
||||
if err != nil || got.Response["endpoint"] != "https://override.test" {
|
||||
t.Fatalf("catalog override = %#v, %v", got, err)
|
||||
t.Fatalf("direct runtime override = %#v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +306,7 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
|
||||
inv := executor.Invocation{CanonicalProduct: "stdio-product", Tool: "tool"}
|
||||
wantErr := errors.New("stdio failed")
|
||||
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return wantErr }
|
||||
if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "stdio initialize failed") {
|
||||
if _, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", inv); err == nil || !strings.Contains(err.Error(), "stdio initialize failed") {
|
||||
t.Fatalf("stdio initialize error = %v", err)
|
||||
}
|
||||
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
|
||||
@@ -322,19 +321,19 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
|
||||
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{}, wantErr
|
||||
}
|
||||
if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "stdio failed") {
|
||||
if _, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", inv); err == nil || !strings.Contains(err.Error(), "stdio failed") {
|
||||
t.Fatalf("stdio call error = %v", err)
|
||||
}
|
||||
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{IsError: true, Content: map[string]any{"message": "tool failed"}}, nil
|
||||
}
|
||||
if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "tool failed") {
|
||||
if _, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", inv); err == nil || !strings.Contains(err.Error(), "tool failed") {
|
||||
t.Fatalf("stdio tool error = %v", err)
|
||||
}
|
||||
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
|
||||
return transport.ToolCallResult{Content: map[string]any{"ok": true}}, nil
|
||||
}
|
||||
if got, err := r.executeStdioInvocation(context.Background(), inv); err != nil || !got.Invocation.Implemented {
|
||||
if got, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", inv); err != nil || !got.Invocation.Implemented {
|
||||
t.Fatalf("stdio success = %#v, %v", got, err)
|
||||
}
|
||||
RegisterStdioClient("plugin/server-key", client)
|
||||
|
||||
@@ -27,35 +27,42 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
var (
|
||||
manualAgentExamplePlaceholderPattern = regexp.MustCompile(`<([^>]+)>`)
|
||||
manualAgentExampleDryRunJSONPattern = regexp.MustCompile(`(?i)"dry_run"\s*:\s*true`)
|
||||
manualAgentExampleDryRunPlanPattern = regexp.MustCompile(`(?i)"preview_kind"\s*:\s*"plan"`)
|
||||
agentExamplePlaceholderPattern = regexp.MustCompile(`<([^>]+)>`)
|
||||
agentExampleDryRunJSONPattern = regexp.MustCompile(`(?i)"dry_run"\s*:\s*true`)
|
||||
agentExampleDryRunPlanPattern = regexp.MustCompile(`(?i)"preview_kind"\s*:\s*"plan"`)
|
||||
// agentExampleDryRunInvocationPattern recognizes the executor
|
||||
// envelope (and the dev connect preview, which mirrors its shape): the
|
||||
// top-level contract is an invocation that merely embeds the would-be
|
||||
// request inside response, so it must classify as invocation, not request.
|
||||
agentExampleDryRunInvocationPattern = regexp.MustCompile(
|
||||
`"kind"\s*:\s*"(?:(?:helper|compat|workflow)_invocation|connect_preview)"`)
|
||||
)
|
||||
|
||||
// TestManualAgentExamplesContract is the always-on gate. It validates every
|
||||
// TestAgentExamplesContract is the always-on gate. It validates every
|
||||
// example, including contract_only entries, against the live bound Cobra path,
|
||||
// flags, required arguments, constraints, and final typed safety.
|
||||
func TestManualAgentExamplesContract(t *testing.T) {
|
||||
plan := manualAgentExampleExecutionPlan(t)
|
||||
func TestAgentExamplesContract(t *testing.T) {
|
||||
plan := agentExampleExecutionPlan(t)
|
||||
if plan.Total == 0 {
|
||||
t.Fatal("no reviewed Agent examples were contract validated")
|
||||
}
|
||||
t.Logf("Agent example contract: total=%d contract=%d dry_run=%d contract_only=%d", plan.Total, plan.Contract, plan.DryRun, plan.ContractOnly)
|
||||
}
|
||||
|
||||
// TestManualAgentExamplesDryRun first validates every reviewed example against
|
||||
// TestAgentExamplesDryRun first validates every reviewed example against
|
||||
// its real BoundCommand, Cobra required arguments, and final typed constraints.
|
||||
// It then executes only the deterministic, explicitly declared dry_run subset
|
||||
// without injecting --yes. Global flag inheritance is not treated as capability
|
||||
// evidence. Runtime failures never create implicit skips. No shell is involved
|
||||
// and HOME is isolated.
|
||||
func TestManualAgentExamplesDryRun(t *testing.T) {
|
||||
func TestAgentExamplesDryRun(t *testing.T) {
|
||||
if os.Getenv("DWS_AGENT_EXAMPLES_DRY_RUN") != "1" {
|
||||
t.Skip("set DWS_AGENT_EXAMPLES_DRY_RUN=1 to execute the explicitly reviewed Agent dry-run subset")
|
||||
}
|
||||
@@ -74,45 +81,45 @@ func TestManualAgentExamplesDryRun(t *testing.T) {
|
||||
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1")
|
||||
t.Setenv("NO_PROXY", "")
|
||||
|
||||
plan := manualAgentExampleExecutionPlan(t)
|
||||
plan := agentExampleExecutionPlan(t)
|
||||
if plan.Total == 0 {
|
||||
t.Fatal("no reviewed Agent examples were contract validated")
|
||||
}
|
||||
t.Chdir(sandboxRoot)
|
||||
files := newManualAgentExampleFiles(t, sandboxRoot)
|
||||
files := newAgentExampleFiles(t, sandboxRoot)
|
||||
|
||||
selected := 0
|
||||
executed := 0
|
||||
for _, execution := range plan.Examples {
|
||||
if !manualAgentExampleShouldExerciseDryRun(execution) {
|
||||
if !agentExampleShouldExerciseDryRun(execution) {
|
||||
continue
|
||||
}
|
||||
selected++
|
||||
execution := execution
|
||||
t.Run(fmt.Sprintf("%s/%d", strings.ReplaceAll(execution.CanonicalPath, ".", "/"), execution.Index), func(t *testing.T) {
|
||||
argv, err := cli.ParseManualAgentExampleArgv(execution.Example)
|
||||
argv, err := cli.ParseAgentExampleArgv(execution.Example)
|
||||
if err != nil {
|
||||
t.Fatalf("parse example %q: %v", execution.Example, err)
|
||||
}
|
||||
args := materializeManualAgentExampleArgv(argv[1:], files)
|
||||
if manualAgentExampleHasFlag(args, "yes") {
|
||||
args := materializeAgentExampleArgv(argv[1:], files)
|
||||
if agentExampleHasFlag(args, "yes") {
|
||||
t.Fatalf("dry-run gate must not inject or accept --yes\nsource: %s\nargv: %q", execution.Example, args)
|
||||
}
|
||||
if !manualAgentExampleHasFlag(args, "dry-run") {
|
||||
if !agentExampleHasFlag(args, "dry-run") {
|
||||
args = append([]string{"--dry-run"}, args...)
|
||||
}
|
||||
|
||||
capture, err := executeManualAgentExampleCapture(t, args)
|
||||
capture, err := executeAgentExampleCapture(t, args)
|
||||
if capture.ToolCallAttempts != 0 {
|
||||
t.Fatalf("eligible dry-run attempted %d ToolCaller invocation(s)\nsource: %s\nargv: %q\noutput:\n%s", capture.ToolCallAttempts, execution.Example, args, capture.Output)
|
||||
}
|
||||
if capture.StdinBytesRead != 0 || manualAgentExamplePromptObserved(capture.Output) {
|
||||
if capture.StdinBytesRead != 0 || agentExamplePromptObserved(capture.Output) {
|
||||
t.Fatalf("eligible dry-run entered an interactive confirmation path (stdin bytes read: %d)\nsource: %s\nargv: %q\noutput:\n%s", capture.StdinBytesRead, execution.Example, args, capture.Output)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run example failed: %v\nsource: %s\nargv: %q\noutput:\n%s", err, execution.Example, args, capture.Output)
|
||||
}
|
||||
previewKind, observed := manualAgentExampleDryRunEvidence(capture)
|
||||
previewKind, observed := agentExampleDryRunEvidence(capture)
|
||||
if !observed {
|
||||
t.Fatalf("example returned without audited dry-run evidence (caller dry-run checks: %d)\nsource: %s\nargv: %q\noutput:\n%s", capture.DryRunChecks, execution.Example, args, capture.Output)
|
||||
}
|
||||
@@ -133,27 +140,20 @@ func TestManualAgentExamplesDryRun(t *testing.T) {
|
||||
}
|
||||
sort.Strings(reasonCodes)
|
||||
for _, reasonCode := range reasonCodes {
|
||||
t.Logf("Agent examples contract_only[%s]=%d", reasonCode, plan.ContractOnlyByReason[cli.ManualAgentExampleReasonCode(reasonCode)])
|
||||
t.Logf("Agent examples contract_only[%s]=%d", reasonCode, plan.ContractOnlyByReason[cli.AgentExampleReasonCode(reasonCode)])
|
||||
}
|
||||
}
|
||||
|
||||
// manualAgentExampleShouldExerciseDryRun is the single selection boundary for
|
||||
// agentExampleShouldExerciseDryRun is the single selection boundary for
|
||||
// the runtime gate. Capability comes only from the final typed ToolSpec; the
|
||||
// example disposition may narrow that set but can never invent support.
|
||||
func manualAgentExampleShouldExerciseDryRun(execution cli.ManualAgentExampleExecution) bool {
|
||||
return execution.DryRun != nil && execution.Mode == cli.ManualAgentExampleModeDryRun
|
||||
func agentExampleShouldExerciseDryRun(execution cli.AgentExampleExecution) bool {
|
||||
return execution.DryRun != nil && execution.Mode == cli.AgentExampleModeDryRun
|
||||
}
|
||||
|
||||
func manualAgentExampleExecutionPlan(t testing.TB) cli.ManualAgentExampleExecutionPlan {
|
||||
func agentExampleExecutionPlan(t testing.TB) cli.AgentExampleExecutionPlan {
|
||||
t.Helper()
|
||||
hints, err := cli.LoadAgentHintsFromSelectionForValidation(os.DirFS("../cli/schema_hints/selection"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentHintsFromSelectionForValidation() error = %v", err)
|
||||
}
|
||||
contractRoot := NewRootCommand()
|
||||
if _, err := cli.ApplyEmbeddedManualSchemaHints(contractRoot); err != nil {
|
||||
t.Fatalf("ApplyEmbeddedManualSchemaHints() error = %v", err)
|
||||
}
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(contractRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEffectiveCommandRegistry() error = %v", err)
|
||||
@@ -169,41 +169,41 @@ func manualAgentExampleExecutionPlan(t testing.TB) cli.ManualAgentExampleExecuti
|
||||
if err := cli.ValidateReviewedDryRunCapabilityDelivery(registry); err != nil {
|
||||
t.Fatalf("ValidateReviewedDryRunCapabilityDelivery() error = %v", err)
|
||||
}
|
||||
plan, err := cli.BuildManualAgentExampleExecutionPlan(bound, registry, hints)
|
||||
plan, err := cli.BuildAgentExampleExecutionPlan(bound, registry)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManualAgentExampleExecutionPlan() error = %v", err)
|
||||
t.Fatalf("BuildAgentExampleExecutionPlan() error = %v", err)
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
type manualAgentExampleCapture struct {
|
||||
type agentExampleCapture struct {
|
||||
Output string
|
||||
DryRunChecks int64
|
||||
ToolCallAttempts int64
|
||||
StdinBytesRead int64
|
||||
}
|
||||
|
||||
type manualAgentExampleFailClosedCaller struct {
|
||||
type agentExampleFailClosedCaller struct {
|
||||
dryRunChecks atomic.Int64
|
||||
toolCallAttempts atomic.Int64
|
||||
}
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
func (c *agentExampleFailClosedCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
c.toolCallAttempts.Add(1)
|
||||
return nil, fmt.Errorf("real ToolCaller invocation blocked during Agent example dry-run: %s/%s", productID, toolName)
|
||||
}
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) Format() string { return "json" }
|
||||
func (c *agentExampleFailClosedCaller) Format() string { return "json" }
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) DryRun() bool {
|
||||
func (c *agentExampleFailClosedCaller) DryRun() bool {
|
||||
c.dryRunChecks.Add(1)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *manualAgentExampleFailClosedCaller) Fields() string { return "" }
|
||||
func (c *manualAgentExampleFailClosedCaller) JQ() string { return "" }
|
||||
func (c *agentExampleFailClosedCaller) Fields() string { return "" }
|
||||
func (c *agentExampleFailClosedCaller) JQ() string { return "" }
|
||||
|
||||
func executeManualAgentExampleCapture(t testing.TB, args []string) (manualAgentExampleCapture, error) {
|
||||
func executeAgentExampleCapture(t testing.TB, args []string) (agentExampleCapture, error) {
|
||||
t.Helper()
|
||||
oldArgs := os.Args
|
||||
os.Args = append([]string{"dws"}, args...)
|
||||
@@ -239,7 +239,7 @@ func executeManualAgentExampleCapture(t testing.TB, args []string) (manualAgentE
|
||||
|
||||
root := NewRootCommand()
|
||||
originalCaller := helpers.GetCaller()
|
||||
auditCaller := &manualAgentExampleFailClosedCaller{}
|
||||
auditCaller := &agentExampleFailClosedCaller{}
|
||||
helpers.InitDeps(auditCaller)
|
||||
defer helpers.InitDeps(originalCaller)
|
||||
var output bytes.Buffer
|
||||
@@ -261,7 +261,7 @@ func executeManualAgentExampleCapture(t testing.TB, args []string) (manualAgentE
|
||||
if err != nil {
|
||||
t.Fatalf("inspect guarded stdin: %v", err)
|
||||
}
|
||||
return manualAgentExampleCapture{
|
||||
return agentExampleCapture{
|
||||
Output: output.String() + string(captured),
|
||||
DryRunChecks: auditCaller.dryRunChecks.Load(),
|
||||
ToolCallAttempts: auditCaller.toolCallAttempts.Load(),
|
||||
@@ -269,7 +269,7 @@ func executeManualAgentExampleCapture(t testing.TB, args []string) (manualAgentE
|
||||
}, execErr
|
||||
}
|
||||
|
||||
type manualAgentExampleFiles struct {
|
||||
type agentExampleFiles struct {
|
||||
root string
|
||||
markdown string
|
||||
json string
|
||||
@@ -278,7 +278,7 @@ type manualAgentExampleFiles struct {
|
||||
image string
|
||||
}
|
||||
|
||||
func newManualAgentExampleFiles(t testing.TB, root string) manualAgentExampleFiles {
|
||||
func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
|
||||
t.Helper()
|
||||
markdown := filepath.Join(root, "content.md")
|
||||
jsonFile := filepath.Join(root, "report.json")
|
||||
@@ -296,13 +296,13 @@ func newManualAgentExampleFiles(t testing.TB, root string) manualAgentExampleFil
|
||||
t.Fatalf("write dry-run fixture %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return manualAgentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
|
||||
return agentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
|
||||
}
|
||||
|
||||
func materializeManualAgentExampleArgv(argv []string, files manualAgentExampleFiles) []string {
|
||||
func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []string {
|
||||
result := append([]string(nil), argv...)
|
||||
for index := range result {
|
||||
result[index] = manualAgentExamplePlaceholderPattern.ReplaceAllStringFunc(result[index], func(match string) string {
|
||||
result[index] = agentExamplePlaceholderPattern.ReplaceAllStringFunc(result[index], func(match string) string {
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(match, "<"), ">")
|
||||
switch strings.ToLower(name) {
|
||||
case "basetime", "remindertimestamp", "reminder-time-stamp":
|
||||
@@ -323,7 +323,7 @@ func materializeManualAgentExampleArgv(argv []string, files manualAgentExampleFi
|
||||
}
|
||||
|
||||
for index := 0; index < len(result); index++ {
|
||||
name, inline, ok := manualAgentExampleLongFlag(result[index])
|
||||
name, inline, ok := agentExampleLongFlag(result[index])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -368,7 +368,7 @@ func materializeManualAgentExampleArgv(argv []string, files manualAgentExampleFi
|
||||
return result
|
||||
}
|
||||
|
||||
func manualAgentExampleLongFlag(argument string) (name, inline string, ok bool) {
|
||||
func agentExampleLongFlag(argument string) (name, inline string, ok bool) {
|
||||
if !strings.HasPrefix(argument, "--") {
|
||||
return "", "", false
|
||||
}
|
||||
@@ -376,7 +376,7 @@ func manualAgentExampleLongFlag(argument string) (name, inline string, ok bool)
|
||||
return name, inline, name != ""
|
||||
}
|
||||
|
||||
func manualAgentExampleHasFlag(argv []string, target string) bool {
|
||||
func agentExampleHasFlag(argv []string, target string) bool {
|
||||
for _, argument := range argv {
|
||||
if argument == "--"+target || strings.HasPrefix(argument, "--"+target+"=") {
|
||||
return true
|
||||
@@ -385,33 +385,36 @@ func manualAgentExampleHasFlag(argv []string, target string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func manualAgentExampleDryRunObserved(capture manualAgentExampleCapture) bool {
|
||||
_, ok := manualAgentExampleDryRunEvidence(capture)
|
||||
func agentExampleDryRunObserved(capture agentExampleCapture) bool {
|
||||
_, ok := agentExampleDryRunEvidence(capture)
|
||||
return ok
|
||||
}
|
||||
|
||||
func manualAgentExampleDryRunEvidence(capture manualAgentExampleCapture) (string, bool) {
|
||||
func agentExampleDryRunEvidence(capture agentExampleCapture) (string, bool) {
|
||||
normalized := strings.ToLower(capture.Output)
|
||||
if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) && manualAgentExampleDryRunPlanPattern.MatchString(capture.Output) {
|
||||
return cli.DryRunPreviewPlan, true
|
||||
if agentExampleDryRunJSONPattern.MatchString(capture.Output) && agentExampleDryRunPlanPattern.MatchString(capture.Output) {
|
||||
return contract.DryRunPreviewPlan, true
|
||||
}
|
||||
if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) {
|
||||
return cli.DryRunPreviewRequest, true
|
||||
if agentExampleDryRunJSONPattern.MatchString(capture.Output) && agentExampleDryRunInvocationPattern.MatchString(capture.Output) {
|
||||
return contract.DryRunPreviewInvocation, true
|
||||
}
|
||||
if agentExampleDryRunJSONPattern.MatchString(capture.Output) {
|
||||
return contract.DryRunPreviewRequest, true
|
||||
}
|
||||
if strings.Contains(normalized, "[dry-run]") {
|
||||
return cli.DryRunPreviewInvocation, true
|
||||
return contract.DryRunPreviewInvocation, true
|
||||
}
|
||||
if capture.DryRunChecks > 0 && strings.Contains(capture.Output, "操作:") {
|
||||
return cli.DryRunPreviewPlan, true
|
||||
return contract.DryRunPreviewPlan, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestManualAgentExampleDryRunEvidenceAcceptsSharedAndCommandPlans(t *testing.T) {
|
||||
if !manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: "[DRY-RUN] Preview only, not executed:\nTool: calendar_list"}) {
|
||||
func TestAgentExampleDryRunEvidenceAcceptsSharedAndCommandPlans(t *testing.T) {
|
||||
if !agentExampleDryRunObserved(agentExampleCapture{Output: "[DRY-RUN] Preview only, not executed:\nTool: calendar_list"}) {
|
||||
t.Fatal("dry-run output with a Tool and nil Arguments was not recognized")
|
||||
}
|
||||
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: "Tool: calendar_list"}) {
|
||||
if agentExampleDryRunObserved(agentExampleCapture{Output: "Tool: calendar_list"}) {
|
||||
t.Fatal("a Tool line without dry-run evidence must not be accepted")
|
||||
}
|
||||
for _, falseEvidence := range []string{
|
||||
@@ -419,28 +422,28 @@ func TestManualAgentExampleDryRunEvidenceAcceptsSharedAndCommandPlans(t *testing
|
||||
"Run again with --dry-run to preview the operation",
|
||||
`{"dry_run":false,"executed":true}`,
|
||||
} {
|
||||
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: falseEvidence}) {
|
||||
if agentExampleDryRunObserved(agentExampleCapture{Output: falseEvidence}) {
|
||||
t.Errorf("non-evidence text was mistaken for a successful dry-run: %q", falseEvidence)
|
||||
}
|
||||
}
|
||||
operationSummary := "操作: 下载钉盘文件\n文件ID: test"
|
||||
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: operationSummary}) {
|
||||
if agentExampleDryRunObserved(agentExampleCapture{Output: operationSummary}) {
|
||||
t.Fatal("a human-only operation summary without an audited dry-run check must not be accepted")
|
||||
}
|
||||
if !manualAgentExampleDryRunObserved(manualAgentExampleCapture{Output: operationSummary, DryRunChecks: 1}) {
|
||||
if !agentExampleDryRunObserved(agentExampleCapture{Output: operationSummary, DryRunChecks: 1}) {
|
||||
t.Fatal("a command plan guarded by the injected caller's dry-run check was not recognized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageManualAgentExampleDryRunEvidenceClassifiesStructuredPlan(t *testing.T) {
|
||||
kind, observed := manualAgentExampleDryRunEvidence(manualAgentExampleCapture{
|
||||
func TestCrossPlatformCoverageAgentExampleDryRunEvidenceClassifiesStructuredPlan(t *testing.T) {
|
||||
kind, observed := agentExampleDryRunEvidence(agentExampleCapture{
|
||||
Output: `{"dry_run":true,"executed":false,"preview_kind":"plan"}`,
|
||||
DryRunChecks: 1,
|
||||
})
|
||||
if !observed || kind != cli.DryRunPreviewPlan {
|
||||
if !observed || kind != contract.DryRunPreviewPlan {
|
||||
t.Fatalf("structured plan classified as kind=%q observed=%v", kind, observed)
|
||||
}
|
||||
if manualAgentExampleDryRunObserved(manualAgentExampleCapture{
|
||||
if agentExampleDryRunObserved(agentExampleCapture{
|
||||
Output: `{"dry_run":false,"executed":false,"preview_kind":"plan"}`,
|
||||
DryRunChecks: 1,
|
||||
}) {
|
||||
@@ -448,7 +451,38 @@ func TestCrossPlatformCoverageManualAgentExampleDryRunEvidenceClassifiesStructur
|
||||
}
|
||||
}
|
||||
|
||||
func manualAgentExamplePromptObserved(output string) bool {
|
||||
func TestAgentExampleDryRunEvidenceClassifiesExecutorEnvelopeAsInvocation(t *testing.T) {
|
||||
// Executor dry-run envelope: invocation at top level, the would-be request
|
||||
// nested inside response — must classify as invocation, never request.
|
||||
envelope := `{
|
||||
"invocation": {"kind": "helper_invocation", "stage": "helper_override", "implemented": false,
|
||||
"dry_run": true, "canonical_product": "devapp", "tool": "create_dev_app"},
|
||||
"response": {"dry_run": true, "request": {"jsonrpc": "2.0", "method": "tools/call"},
|
||||
"note": "execution skipped by --dry-run"}
|
||||
}`
|
||||
kind, observed := agentExampleDryRunEvidence(agentExampleCapture{Output: envelope})
|
||||
if !observed || kind != contract.DryRunPreviewInvocation {
|
||||
t.Fatalf("executor envelope classified as kind=%q observed=%v, want invocation", kind, observed)
|
||||
}
|
||||
for _, invKind := range []string{"compat_invocation", "workflow_invocation", "connect_preview"} {
|
||||
out := strings.Replace(envelope, "helper_invocation", invKind, 1)
|
||||
if kind, ok := agentExampleDryRunEvidence(agentExampleCapture{Output: out}); !ok || kind != contract.DryRunPreviewInvocation {
|
||||
t.Fatalf("kind %q envelope classified as %q/%v, want invocation", invKind, kind, ok)
|
||||
}
|
||||
}
|
||||
// A bare MCP dry-run document has no invocation kind and stays request.
|
||||
mcpDoc := `{"dry_run": true, "executed": false, "tool": "get_dev_app", "arguments": {}}`
|
||||
if kind, ok := agentExampleDryRunEvidence(agentExampleCapture{Output: mcpDoc}); !ok || kind != contract.DryRunPreviewRequest {
|
||||
t.Fatalf("bare MCP dry-run classified as %q/%v, want request", kind, ok)
|
||||
}
|
||||
// Plan still wins over an invocation-shaped payload.
|
||||
planDoc := `{"dry_run": true, "preview_kind": "plan", "kind": "helper_invocation"}`
|
||||
if kind, ok := agentExampleDryRunEvidence(agentExampleCapture{Output: planDoc}); !ok || kind != contract.DryRunPreviewPlan {
|
||||
t.Fatalf("plan precedence broken: %q/%v", kind, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func agentExamplePromptObserved(output string) bool {
|
||||
normalized := strings.ToLower(output)
|
||||
for _, marker := range []string{
|
||||
"confirm ",
|
||||
@@ -469,7 +503,7 @@ func manualAgentExamplePromptObserved(output string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func TestManualAgentExamplePromptObservedRejectsInteractiveConfirmation(t *testing.T) {
|
||||
func TestAgentExamplePromptObservedRejectsInteractiveConfirmation(t *testing.T) {
|
||||
for _, prompt := range []string{
|
||||
"Confirm deletion? (yes/no):",
|
||||
"Confirm action? (yes/no):",
|
||||
@@ -480,11 +514,11 @@ func TestManualAgentExamplePromptObservedRejectsInteractiveConfirmation(t *testi
|
||||
"Are you sure you want to continue?",
|
||||
"Operation cancelled",
|
||||
} {
|
||||
if !manualAgentExamplePromptObserved(prompt) {
|
||||
if !agentExamplePromptObserved(prompt) {
|
||||
t.Errorf("interactive confirmation output was not detected: %q", prompt)
|
||||
}
|
||||
}
|
||||
if manualAgentExamplePromptObserved(`{"dry_run":true,"confirmation":"user_required"}`) {
|
||||
if agentExamplePromptObserved(`{"dry_run":true,"confirmation":"user_required"}`) {
|
||||
t.Fatal("typed safety metadata was mistaken for an interactive prompt")
|
||||
}
|
||||
}
|
||||
@@ -498,26 +532,26 @@ func TestAitableAdvpermDisableDryRunSkipsConfirmationAndToolCall(t *testing.T) {
|
||||
"aitable", "advperm", "disable",
|
||||
"--base-id", "BASE_ID",
|
||||
}
|
||||
if manualAgentExampleHasFlag(args, "yes") {
|
||||
if agentExampleHasFlag(args, "yes") {
|
||||
t.Fatal("regression test must not bypass confirmation with --yes")
|
||||
}
|
||||
capture, err := executeManualAgentExampleCapture(t, args)
|
||||
capture, err := executeAgentExampleCapture(t, args)
|
||||
if err != nil {
|
||||
t.Fatalf("advperm disable fail-closed dry-run failed: %v\noutput:\n%s", err, capture.Output)
|
||||
}
|
||||
if capture.StdinBytesRead != 0 || manualAgentExamplePromptObserved(capture.Output) {
|
||||
if capture.StdinBytesRead != 0 || agentExamplePromptObserved(capture.Output) {
|
||||
t.Fatalf("advperm disable dry-run entered confirmation (stdin bytes read: %d)\noutput:\n%s", capture.StdinBytesRead, capture.Output)
|
||||
}
|
||||
if capture.ToolCallAttempts != 0 {
|
||||
t.Fatalf("advperm disable dry-run attempted %d real ToolCaller invocation(s)\noutput:\n%s", capture.ToolCallAttempts, capture.Output)
|
||||
}
|
||||
if !manualAgentExampleDryRunObserved(capture) {
|
||||
if !agentExampleDryRunObserved(capture) {
|
||||
t.Fatalf("advperm disable returned no audited dry-run evidence (caller dry-run checks: %d)\noutput:\n%s", capture.DryRunChecks, capture.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualAgentExampleFailClosedCallerRecordsToolCalls(t *testing.T) {
|
||||
caller := &manualAgentExampleFailClosedCaller{}
|
||||
func TestAgentExampleFailClosedCallerRecordsToolCalls(t *testing.T) {
|
||||
caller := &agentExampleFailClosedCaller{}
|
||||
if !caller.DryRun() {
|
||||
t.Fatal("fail-closed caller must advertise dry-run mode")
|
||||
}
|
||||
@@ -532,7 +566,7 @@ func TestManualAgentExampleFailClosedCallerRecordsToolCalls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualAgentExampleChatGroupMuteMemberUsesCommandDryRunPreview(t *testing.T) {
|
||||
func TestAgentExampleChatGroupMuteMemberUsesCommandDryRunPreview(t *testing.T) {
|
||||
sandboxRoot := t.TempDir()
|
||||
configDir := filepath.Join(sandboxRoot, "config")
|
||||
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||
@@ -541,7 +575,7 @@ func TestManualAgentExampleChatGroupMuteMemberUsesCommandDryRunPreview(t *testin
|
||||
setTestHome(t, sandboxRoot)
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
capture, err := executeManualAgentExampleCapture(t, []string{
|
||||
capture, err := executeAgentExampleCapture(t, []string{
|
||||
"--dry-run",
|
||||
"chat", "group-mute-member",
|
||||
"--group", "test_openConversationId",
|
||||
@@ -557,10 +591,10 @@ func TestManualAgentExampleChatGroupMuteMemberUsesCommandDryRunPreview(t *testin
|
||||
if capture.DryRunChecks == 0 {
|
||||
t.Fatalf("group-mute-member did not enter its audited command dry-run path\noutput:\n%s", capture.Output)
|
||||
}
|
||||
if capture.StdinBytesRead != 0 || manualAgentExamplePromptObserved(capture.Output) {
|
||||
if capture.StdinBytesRead != 0 || agentExamplePromptObserved(capture.Output) {
|
||||
t.Fatalf("group-mute-member dry-run entered an interactive prompt (stdin bytes read: %d)\noutput:\n%s", capture.StdinBytesRead, capture.Output)
|
||||
}
|
||||
if !manualAgentExampleDryRunObserved(capture) {
|
||||
if !agentExampleDryRunObserved(capture) {
|
||||
t.Fatalf("group-mute-member returned no audited dry-run evidence\noutput:\n%s", capture.Output)
|
||||
}
|
||||
for _, expected := range []string{`"uids"`, `"userId1"`, `"userId2"`} {
|
||||
|
||||
@@ -14,13 +14,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
func TestManualAgentSelectionScenariosCoverEveryExecutableSchemaTool(t *testing.T) {
|
||||
func TestAgentSelectionScenariosCoverEveryExecutableSchemaTool(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
@@ -30,14 +29,9 @@ func TestManualAgentSelectionScenariosCoverEveryExecutableSchemaTool(t *testing.
|
||||
if err != nil {
|
||||
t.Fatalf("BindEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
hints, err := cli.LoadAgentHintsFromSelectionForValidation(os.DirFS("../cli/schema_hints/selection"))
|
||||
fixture, report, err := cli.BuildAgentSelectionEvalFixture(bound)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentHintsFromSelectionForValidation() error = %v", err)
|
||||
}
|
||||
|
||||
fixture, report, err := cli.BuildManualAgentSelectionEvalFixture(bound, hints)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManualAgentSelectionEvalFixture() error = %v", err)
|
||||
t.Fatalf("BuildAgentSelectionEvalFixture() error = %v", err)
|
||||
}
|
||||
if report.Tools != len(bound.Commands) {
|
||||
t.Fatalf("selection tools = %d, bound commands = %d", report.Tools, len(bound.Commands))
|
||||
|
||||
@@ -31,42 +31,42 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
const manualAgentSelectionLiveBatchSize = 12
|
||||
const agentSelectionLiveBatchSize = 12
|
||||
|
||||
type manualAgentSelectionLiveCandidate struct {
|
||||
type agentSelectionLiveCandidate struct {
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
AgentSummary string `json:"agent_summary"`
|
||||
UseWhen []string `json:"use_when"`
|
||||
AvoidWhen []string `json:"avoid_when"`
|
||||
}
|
||||
|
||||
type manualAgentSelectionLiveInput struct {
|
||||
Cases []manualAgentSelectionLiveCase `json:"cases"`
|
||||
Candidates []manualAgentSelectionLiveCandidate `json:"candidates"`
|
||||
type agentSelectionLiveInput struct {
|
||||
Cases []agentSelectionLiveCase `json:"cases"`
|
||||
Candidates []agentSelectionLiveCandidate `json:"candidates"`
|
||||
}
|
||||
|
||||
// manualAgentSelectionLiveCase is deliberately answer-free. Expected and
|
||||
// agentSelectionLiveCase is deliberately answer-free. Expected and
|
||||
// forbidden canonicals stay only in the local assertion fixture and are never
|
||||
// sent to the model being evaluated.
|
||||
type manualAgentSelectionLiveCase struct {
|
||||
type agentSelectionLiveCase struct {
|
||||
ID string `json:"id"`
|
||||
Scenario string `json:"scenario"`
|
||||
}
|
||||
|
||||
type manualAgentSelectionLiveResult struct {
|
||||
type agentSelectionLiveResult struct {
|
||||
ID string `json:"id"`
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
}
|
||||
|
||||
type manualAgentSelectionLiveResponse struct {
|
||||
Results []manualAgentSelectionLiveResult `json:"results"`
|
||||
type agentSelectionLiveResponse struct {
|
||||
Results []agentSelectionLiveResult `json:"results"`
|
||||
}
|
||||
|
||||
// TestManualAgentSelectionArkLive is intentionally opt-in. Deterministic CI
|
||||
// TestAgentSelectionArkLive is intentionally opt-in. Deterministic CI
|
||||
// validates all fixture and Cobra facts without network access; this test asks
|
||||
// a real model to interpret the reviewed natural-language scenarios. Set
|
||||
// DWS_AGENT_SELECTION_FULL=1 to evaluate every positive and negative case.
|
||||
func TestManualAgentSelectionArkLive(t *testing.T) {
|
||||
func TestAgentSelectionArkLive(t *testing.T) {
|
||||
if os.Getenv("DWS_AGENT_SELECTION_LIVE") != "1" {
|
||||
t.Skip("set DWS_AGENT_SELECTION_LIVE=1 and ARK_API_KEY/ARK_BASE_URL/ARK_MODEL to run live Agent command-selection evaluation")
|
||||
}
|
||||
@@ -82,47 +82,47 @@ func TestManualAgentSelectionArkLive(t *testing.T) {
|
||||
t.Fatalf("%s is required when DWS_AGENT_SELECTION_LIVE=1", name)
|
||||
}
|
||||
}
|
||||
if err := validateManualAgentSelectionLiveBaseURL(baseURL, os.Getenv("DWS_AGENT_SELECTION_ALLOWED_BASE_URLS")); err != nil {
|
||||
if err := validateAgentSelectionLiveBaseURL(baseURL, os.Getenv("DWS_AGENT_SELECTION_ALLOWED_BASE_URLS")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fixture, hints := manualAgentSelectionLiveFixture(t)
|
||||
cases := selectManualAgentSelectionLiveCases(t, fixture.Cases)
|
||||
for _, batch := range batchManualAgentSelectionLiveCases(cases, manualAgentSelectionLiveBatchSize) {
|
||||
fixture, selectionSet := agentSelectionLiveFixture(t)
|
||||
cases := selectAgentSelectionLiveCases(t, fixture.Cases)
|
||||
for _, batch := range batchAgentSelectionLiveCases(cases, agentSelectionLiveBatchSize) {
|
||||
productID := batch[0].ProductID
|
||||
t.Run(productID+"/"+sanitizeManualAgentSelectionLiveTestID(batch[0].ID), func(t *testing.T) {
|
||||
input := buildManualAgentSelectionLiveInput(batch, hints)
|
||||
results := callManualAgentSelectionLiveModel(t, baseURL, apiKey, model, input)
|
||||
assertManualAgentSelectionLiveResults(t, batch, results)
|
||||
t.Run(productID+"/"+sanitizeAgentSelectionLiveTestID(batch[0].ID), func(t *testing.T) {
|
||||
input := buildAgentSelectionLiveInput(batch, selectionSet)
|
||||
results := callAgentSelectionLiveModel(t, baseURL, apiKey, model, input)
|
||||
assertAgentSelectionLiveResults(t, batch, results)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func buildManualAgentSelectionLiveInput(batch []cli.ManualAgentSelectionCase, hints cli.ManualAgentHintSet) manualAgentSelectionLiveInput {
|
||||
input := manualAgentSelectionLiveInput{Cases: make([]manualAgentSelectionLiveCase, 0, len(batch))}
|
||||
func buildAgentSelectionLiveInput(batch []cli.AgentSelectionCase, selectionSet cli.FixtureAgentSelectionSet) agentSelectionLiveInput {
|
||||
input := agentSelectionLiveInput{Cases: make([]agentSelectionLiveCase, 0, len(batch))}
|
||||
if len(batch) == 0 {
|
||||
return input
|
||||
}
|
||||
for _, selectionCase := range batch {
|
||||
input.Cases = append(input.Cases, manualAgentSelectionLiveCase{
|
||||
input.Cases = append(input.Cases, agentSelectionLiveCase{
|
||||
ID: selectionCase.ID,
|
||||
Scenario: selectionCase.Scenario,
|
||||
})
|
||||
}
|
||||
input.Candidates = make([]manualAgentSelectionLiveCandidate, 0, len(batch[0].CandidateCanonicals))
|
||||
input.Candidates = make([]agentSelectionLiveCandidate, 0, len(batch[0].CandidateCanonicals))
|
||||
for _, canonical := range batch[0].CandidateCanonicals {
|
||||
hint := hints.Tools[canonical]
|
||||
input.Candidates = append(input.Candidates, manualAgentSelectionLiveCandidate{
|
||||
tool := selectionSet.Tools[canonical]
|
||||
input.Candidates = append(input.Candidates, agentSelectionLiveCandidate{
|
||||
CanonicalPath: canonical,
|
||||
AgentSummary: hint.AgentSummary,
|
||||
UseWhen: hint.UseWhen,
|
||||
AvoidWhen: hint.AvoidWhen,
|
||||
AgentSummary: tool.AgentSummary,
|
||||
UseWhen: tool.UseWhen,
|
||||
AvoidWhen: tool.AvoidWhen,
|
||||
})
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func manualAgentSelectionLiveFixture(t testing.TB) (cli.ManualAgentSelectionFixture, cli.ManualAgentHintSet) {
|
||||
func agentSelectionLiveFixture(t testing.TB) (cli.AgentSelectionFixture, cli.FixtureAgentSelectionSet) {
|
||||
t.Helper()
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
@@ -133,18 +133,14 @@ func manualAgentSelectionLiveFixture(t testing.TB) (cli.ManualAgentSelectionFixt
|
||||
if err != nil {
|
||||
t.Fatalf("BindEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
hints, err := cli.LoadAgentHintsFromSelectionForValidation(os.DirFS("../cli/schema_hints/selection"))
|
||||
fixture, _, err := cli.BuildAgentSelectionEvalFixture(bound)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAgentHintsFromSelectionForValidation() error = %v", err)
|
||||
t.Fatalf("BuildAgentSelectionEvalFixture() error = %v", err)
|
||||
}
|
||||
fixture, _, err := cli.BuildManualAgentSelectionEvalFixture(bound, hints)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildManualAgentSelectionEvalFixture() error = %v", err)
|
||||
}
|
||||
return fixture, hints
|
||||
return fixture, cli.FixtureAgentSelectionSet{}
|
||||
}
|
||||
|
||||
func selectManualAgentSelectionLiveCases(t testing.TB, cases []cli.ManualAgentSelectionCase) []cli.ManualAgentSelectionCase {
|
||||
func selectAgentSelectionLiveCases(t testing.TB, cases []cli.AgentSelectionCase) []cli.AgentSelectionCase {
|
||||
t.Helper()
|
||||
if raw := strings.TrimSpace(os.Getenv("DWS_AGENT_SELECTION_CASES")); raw != "" {
|
||||
selected := map[string]bool{}
|
||||
@@ -153,7 +149,7 @@ func selectManualAgentSelectionLiveCases(t testing.TB, cases []cli.ManualAgentSe
|
||||
selected[id] = true
|
||||
}
|
||||
}
|
||||
result := make([]cli.ManualAgentSelectionCase, 0, len(selected))
|
||||
result := make([]cli.AgentSelectionCase, 0, len(selected))
|
||||
for _, selectionCase := range cases {
|
||||
if selected[selectionCase.ID] {
|
||||
result = append(result, selectionCase)
|
||||
@@ -171,13 +167,13 @@ func selectManualAgentSelectionLiveCases(t testing.TB, cases []cli.ManualAgentSe
|
||||
return result
|
||||
}
|
||||
if os.Getenv("DWS_AGENT_SELECTION_FULL") == "1" {
|
||||
return append([]cli.ManualAgentSelectionCase(nil), cases...)
|
||||
return append([]cli.AgentSelectionCase(nil), cases...)
|
||||
}
|
||||
|
||||
// Smoke mode exercises one positive and one negative scenario per product.
|
||||
seenPositive := map[string]bool{}
|
||||
seenNegative := map[string]bool{}
|
||||
result := make([]cli.ManualAgentSelectionCase, 0)
|
||||
result := make([]cli.AgentSelectionCase, 0)
|
||||
for _, selectionCase := range cases {
|
||||
if selectionCase.ExpectedCanonical != "" && !seenPositive[selectionCase.ProductID] {
|
||||
seenPositive[selectionCase.ProductID] = true
|
||||
@@ -191,11 +187,11 @@ func selectManualAgentSelectionLiveCases(t testing.TB, cases []cli.ManualAgentSe
|
||||
return result
|
||||
}
|
||||
|
||||
func batchManualAgentSelectionLiveCases(cases []cli.ManualAgentSelectionCase, batchSize int) [][]cli.ManualAgentSelectionCase {
|
||||
func batchAgentSelectionLiveCases(cases []cli.AgentSelectionCase, batchSize int) [][]cli.AgentSelectionCase {
|
||||
if batchSize <= 0 {
|
||||
batchSize = 1
|
||||
}
|
||||
grouped := map[string][]cli.ManualAgentSelectionCase{}
|
||||
grouped := map[string][]cli.AgentSelectionCase{}
|
||||
products := make([]string, 0)
|
||||
for _, selectionCase := range cases {
|
||||
if _, ok := grouped[selectionCase.ProductID]; !ok {
|
||||
@@ -204,7 +200,7 @@ func batchManualAgentSelectionLiveCases(cases []cli.ManualAgentSelectionCase, ba
|
||||
grouped[selectionCase.ProductID] = append(grouped[selectionCase.ProductID], selectionCase)
|
||||
}
|
||||
sort.Strings(products)
|
||||
result := make([][]cli.ManualAgentSelectionCase, 0)
|
||||
result := make([][]cli.AgentSelectionCase, 0)
|
||||
for _, productID := range products {
|
||||
productCases := grouped[productID]
|
||||
for start := 0; start < len(productCases); start += batchSize {
|
||||
@@ -212,15 +208,15 @@ func batchManualAgentSelectionLiveCases(cases []cli.ManualAgentSelectionCase, ba
|
||||
if end > len(productCases) {
|
||||
end = len(productCases)
|
||||
}
|
||||
result = append(result, append([]cli.ManualAgentSelectionCase(nil), productCases[start:end]...))
|
||||
result = append(result, append([]cli.AgentSelectionCase(nil), productCases[start:end]...))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func callManualAgentSelectionLiveModel(t testing.TB, baseURL, apiKey, model string, input manualAgentSelectionLiveInput) []manualAgentSelectionLiveResult {
|
||||
func callAgentSelectionLiveModel(t testing.TB, baseURL, apiKey, model string, input agentSelectionLiveInput) []agentSelectionLiveResult {
|
||||
t.Helper()
|
||||
body, err := marshalManualAgentSelectionLiveRequest(baseURL, model, input)
|
||||
body, err := marshalAgentSelectionLiveRequest(baseURL, model, input)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal live selection request: %v", err)
|
||||
}
|
||||
@@ -260,7 +256,7 @@ func callManualAgentSelectionLiveModel(t testing.TB, baseURL, apiKey, model stri
|
||||
if len(envelope.Choices) == 0 || strings.TrimSpace(envelope.Choices[0].Message.Content) == "" {
|
||||
t.Fatal("live selection response has no model content")
|
||||
}
|
||||
var selection manualAgentSelectionLiveResponse
|
||||
var selection agentSelectionLiveResponse
|
||||
decoder := json.NewDecoder(strings.NewReader(envelope.Choices[0].Message.Content))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&selection); err != nil {
|
||||
@@ -269,7 +265,7 @@ func callManualAgentSelectionLiveModel(t testing.TB, baseURL, apiKey, model stri
|
||||
return selection.Results
|
||||
}
|
||||
|
||||
func marshalManualAgentSelectionLiveRequest(baseURL, model string, input manualAgentSelectionLiveInput) ([]byte, error) {
|
||||
func marshalAgentSelectionLiveRequest(baseURL, model string, input agentSelectionLiveInput) ([]byte, error) {
|
||||
inputJSON, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal live selection input: %w", err)
|
||||
@@ -294,9 +290,9 @@ func marshalManualAgentSelectionLiveRequest(baseURL, model string, input manualA
|
||||
return json.Marshal(requestBody)
|
||||
}
|
||||
|
||||
func assertManualAgentSelectionLiveResults(t testing.TB, cases []cli.ManualAgentSelectionCase, results []manualAgentSelectionLiveResult) {
|
||||
func assertAgentSelectionLiveResults(t testing.TB, cases []cli.AgentSelectionCase, results []agentSelectionLiveResult) {
|
||||
t.Helper()
|
||||
byID := make(map[string]manualAgentSelectionLiveResult, len(results))
|
||||
byID := make(map[string]agentSelectionLiveResult, len(results))
|
||||
for _, result := range results {
|
||||
if _, exists := byID[result.ID]; exists {
|
||||
t.Fatalf("live selection returned duplicate case ID %q", result.ID)
|
||||
@@ -315,7 +311,7 @@ func assertManualAgentSelectionLiveResults(t testing.TB, cases []cli.ManualAgent
|
||||
t.Errorf("live selection returned empty canonical for %q", selectionCase.ID)
|
||||
continue
|
||||
}
|
||||
if selected != "none" && !containsManualAgentSelectionCanonical(selectionCase.CandidateCanonicals, selected) {
|
||||
if selected != "none" && !containsAgentSelectionCanonical(selectionCase.CandidateCanonicals, selected) {
|
||||
t.Errorf("live selection returned non-candidate %q for %q", selected, selectionCase.ID)
|
||||
continue
|
||||
}
|
||||
@@ -336,13 +332,13 @@ func assertManualAgentSelectionLiveResults(t testing.TB, cases []cli.ManualAgent
|
||||
}
|
||||
}
|
||||
|
||||
func validateManualAgentSelectionLiveBaseURL(raw, extraAllowed string) error {
|
||||
func validateAgentSelectionLiveBaseURL(raw, extraAllowed string) error {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil {
|
||||
return fmt.Errorf("ARK_BASE_URL must be an absolute HTTP(S) API base without query or fragment")
|
||||
}
|
||||
if parsed.Scheme == "http" {
|
||||
if !manualAgentSelectionLoopbackHost(parsed.Hostname()) {
|
||||
if !agentSelectionLiveLoopbackHost(parsed.Hostname()) {
|
||||
return fmt.Errorf("ARK_BASE_URL may use plaintext HTTP only for a loopback test endpoint")
|
||||
}
|
||||
return nil
|
||||
@@ -367,7 +363,7 @@ func validateManualAgentSelectionLiveBaseURL(raw, extraAllowed string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func manualAgentSelectionLoopbackHost(host string) bool {
|
||||
func agentSelectionLiveLoopbackHost(host string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(host), "localhost") {
|
||||
return true
|
||||
}
|
||||
@@ -375,7 +371,7 @@ func manualAgentSelectionLoopbackHost(host string) bool {
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func containsManualAgentSelectionCanonical(values []string, target string) bool {
|
||||
func containsAgentSelectionCanonical(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
@@ -384,36 +380,36 @@ func containsManualAgentSelectionCanonical(values []string, target string) bool
|
||||
return false
|
||||
}
|
||||
|
||||
func sanitizeManualAgentSelectionLiveTestID(value string) string {
|
||||
func sanitizeAgentSelectionLiveTestID(value string) string {
|
||||
value = strings.ReplaceAll(value, ".", "_")
|
||||
value = strings.ReplaceAll(value, "/", "_")
|
||||
return value
|
||||
}
|
||||
|
||||
func TestManualAgentSelectionLiveResultContract(t *testing.T) {
|
||||
cases := []cli.ManualAgentSelectionCase{
|
||||
func TestAgentSelectionLiveResultContract(t *testing.T) {
|
||||
cases := []cli.AgentSelectionCase{
|
||||
{ID: "sample.search/use_when/0", Scenario: "find an item", ExpectedCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
{ID: "sample.search/avoid_when/0", Scenario: "create an item", ForbiddenCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
}
|
||||
t.Run("accepts exact positive and negative choices", func(t *testing.T) {
|
||||
assertManualAgentSelectionLiveResults(t, cases, []manualAgentSelectionLiveResult{
|
||||
assertAgentSelectionLiveResults(t, cases, []agentSelectionLiveResult{
|
||||
{ID: cases[0].ID, CanonicalPath: "sample.search"},
|
||||
{ID: cases[1].ID, CanonicalPath: "sample.create"},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestManualAgentSelectionLiveInputDoesNotLeakAssertionsOrRepeatCandidates(t *testing.T) {
|
||||
batch := []cli.ManualAgentSelectionCase{
|
||||
func TestAgentSelectionLiveInputDoesNotLeakAssertionsOrRepeatCandidates(t *testing.T) {
|
||||
batch := []cli.AgentSelectionCase{
|
||||
{ID: "sample.search/use_when/0", Scenario: "find an item", ExpectedCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
{ID: "sample.search/avoid_when/0", Scenario: "create an item", ForbiddenCanonical: "sample.search", CandidateCanonicals: []string{"sample.create", "sample.search"}},
|
||||
}
|
||||
hints := cli.ManualAgentHintSet{Tools: map[string]cli.ManualAgentToolHint{
|
||||
selectionSet := cli.FixtureAgentSelectionSet{Tools: map[string]cli.AgentToolSelection{
|
||||
"sample.create": {AgentSummary: "Create an item", UseWhen: []string{"create"}, AvoidWhen: []string{"find"}},
|
||||
"sample.search": {AgentSummary: "Search items", UseWhen: []string{"find"}, AvoidWhen: []string{"create"}},
|
||||
}}
|
||||
input := buildManualAgentSelectionLiveInput(batch, hints)
|
||||
data, err := marshalManualAgentSelectionLiveRequest("https://ark.cn-beijing.volces.com/api/plan/v3", "fixed-model", input)
|
||||
input := buildAgentSelectionLiveInput(batch, selectionSet)
|
||||
data, err := marshalAgentSelectionLiveRequest("https://ark.cn-beijing.volces.com/api/plan/v3", "fixed-model", input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -430,7 +426,7 @@ func TestManualAgentSelectionLiveInputDoesNotLeakAssertionsOrRepeatCandidates(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManualAgentSelectionLiveBaseURL(t *testing.T) {
|
||||
func TestValidateAgentSelectionLiveBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
@@ -450,7 +446,7 @@ func TestValidateManualAgentSelectionLiveBaseURL(t *testing.T) {
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := validateManualAgentSelectionLiveBaseURL(test.baseURL, test.extraAllowed)
|
||||
err := validateAgentSelectionLiveBaseURL(test.baseURL, test.extraAllowed)
|
||||
if test.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("validate base URL: %v", err)
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
)
|
||||
|
||||
func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) {
|
||||
exclusions, err := cli.EmbeddedRuntimeSchemaExclusions()
|
||||
exclusions, err := cli.ReviewedRuntimeSchemaExclusions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(root); err != nil {
|
||||
if err := cli.ValidateRuntimeSchemaCompleteness(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report := cli.RuntimeSchemaCompleteness(root, exclusions)
|
||||
|
||||
@@ -45,7 +45,7 @@ func fullSchemaSnapshotForTest(t testing.TB) cli.SchemaCatalogSnapshot {
|
||||
return fullSchemaSnapshot
|
||||
}
|
||||
|
||||
func TestEmbeddedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
func TestDeliverySchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
@@ -63,7 +63,7 @@ func TestEmbeddedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", "--all", "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute embedded schema --all: %v; stderr=%s", err, stderr.String())
|
||||
t.Fatalf("execute delivery schema --all: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Products []struct {
|
||||
@@ -73,7 +73,7 @@ func TestEmbeddedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
} `json:"products"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode embedded schema --all: %v", err)
|
||||
t.Fatalf("decode delivery schema --all: %v", err)
|
||||
}
|
||||
actual := make(map[string]bool)
|
||||
var duplicates []string
|
||||
@@ -81,7 +81,7 @@ func TestEmbeddedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
for _, tool := range product.Tools {
|
||||
canonical := strings.TrimSpace(tool.CanonicalPath)
|
||||
if canonical == "" {
|
||||
t.Fatal("embedded schema --all contains an empty canonical path")
|
||||
t.Fatal("delivery schema --all contains an empty canonical path")
|
||||
}
|
||||
if actual[canonical] {
|
||||
duplicates = append(duplicates, canonical)
|
||||
@@ -91,7 +91,7 @@ func TestEmbeddedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
}
|
||||
if len(duplicates) > 0 {
|
||||
sort.Strings(duplicates)
|
||||
t.Fatalf("embedded schema --all contains duplicate canonicals: %v", duplicates)
|
||||
t.Fatalf("delivery schema --all contains duplicate canonicals: %v", duplicates)
|
||||
}
|
||||
|
||||
var missing, extra []string
|
||||
@@ -108,16 +108,16 @@ func TestEmbeddedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
if len(missing) > 0 || len(extra) > 0 {
|
||||
sort.Strings(missing)
|
||||
sort.Strings(extra)
|
||||
t.Fatalf("embedded Schema canonical set differs from EffectiveCommandRegistry: missing=%v extra=%v", missing, extra)
|
||||
t.Fatalf("runtime-assembled Schema canonical set differs from EffectiveCommandRegistry: missing=%v extra=%v", missing, extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedSchemaContractMapsToExecutableTree(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
bindings, err := cli.EmbeddedSchemaParameterBindings()
|
||||
bindings, err := cli.LoadSchemaParameterBindings()
|
||||
if err != nil {
|
||||
t.Fatalf("EmbeddedSchemaParameterBindings() error = %v", err)
|
||||
t.Fatalf("LoadSchemaParameterBindings() error = %v", err)
|
||||
}
|
||||
if len(snapshot.Tools) == 0 {
|
||||
t.Fatal("generated Schema Catalog contains no tools")
|
||||
@@ -370,9 +370,6 @@ func schemaContractStringSlice(value any) []string {
|
||||
// delivered set must always equal the public EffectiveCommandRegistry set.
|
||||
func schemaContractPayloadForBoundCanonicals(t *testing.T, root *cobra.Command, canonicals ...string) cli.SchemaSnapshotPayload {
|
||||
t.Helper()
|
||||
if _, err := cli.ApplyEmbeddedManualSchemaHints(root); err != nil {
|
||||
t.Fatalf("apply manual Schema hints: %v", err)
|
||||
}
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("build effective CommandRegistry: %v", err)
|
||||
@@ -458,14 +455,14 @@ func TestPromptingWritesRequireUserConfirmation(t *testing.T) {
|
||||
"sheet.delete_pivot_table": "medium",
|
||||
}
|
||||
wantSources := map[string]string{
|
||||
"attendance.class_create": "internal/cli/schema_hints/metadata/attendance.json",
|
||||
"attendance.class_update": "internal/cli/schema_hints/metadata/attendance.json",
|
||||
"doc.delete_comment": "internal/cli/schema_hints/metadata/doc.json",
|
||||
"doc.version_revert": "internal/cli/schema_hints/metadata/doc.json",
|
||||
"drive.publish_set": "internal/cli/schema_hints/metadata/drive.json",
|
||||
"drive.publish_unset": "internal/cli/schema_hints/metadata/drive.json",
|
||||
"sheet.chart_delete": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"sheet.delete_pivot_table": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"attendance.class_create": "corecmd.contract",
|
||||
"attendance.class_update": "corecmd.contract",
|
||||
"doc.delete_comment": "corecmd.contract",
|
||||
"doc.version_revert": "corecmd.contract",
|
||||
"drive.publish_set": "corecmd.contract",
|
||||
"drive.publish_unset": "corecmd.contract",
|
||||
"sheet.chart_delete": "corecmd.contract",
|
||||
"sheet.delete_pivot_table": "corecmd.contract",
|
||||
}
|
||||
canonicals := make([]string, 0, len(wantEffects))
|
||||
for canonical := range wantEffects {
|
||||
|
||||
@@ -19,11 +19,11 @@ import (
|
||||
|
||||
// TestFinalSchemaParametersMatchExecutableHelpFlags is the fast, in-process
|
||||
// Help <-> Schema parameter completeness gate. It deliberately starts from the
|
||||
// reviewed registry and its exact Cobra bindings, then compares every public
|
||||
// primary leaf with the final delivered ToolSpec projection. The binder has
|
||||
// already proved that reviewed compatibility leaves have the same executable
|
||||
// contract as their primary, so aliases do not create a second parameter
|
||||
// source here.
|
||||
// collected command identity and its exact Cobra bindings, then compares
|
||||
// every public primary leaf with the final delivered ToolSpec projection. The
|
||||
// binder has already proved that compatibility aliases of a leaf share the
|
||||
// same executable contract as their primary, so aliases do not create a
|
||||
// second parameter source here.
|
||||
func TestFinalSchemaParametersMatchExecutableHelpFlags(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
bound := boundSchemaCommandsForHelpFlagTest(t, root)
|
||||
@@ -31,15 +31,15 @@ func TestFinalSchemaParametersMatchExecutableHelpFlags(t *testing.T) {
|
||||
assertSchemaParametersMatchExecutableHelpFlags(t, bound, snapshot.Tools, "source-built final Schema")
|
||||
}
|
||||
|
||||
// TestEmbeddedSchemaParametersMatchExecutableHelpFlags runs the same exact-set
|
||||
// TestDeliverySchemaParametersMatchExecutableHelpFlags runs the same exact-set
|
||||
// gate against the artifact that ships in the binary. Going through the real
|
||||
// schema --all command is intentional: a stale generated Catalog must fail
|
||||
// even when a fresh source-built snapshot would agree with Cobra Help.
|
||||
func TestEmbeddedSchemaParametersMatchExecutableHelpFlags(t *testing.T) {
|
||||
func TestDeliverySchemaParametersMatchExecutableHelpFlags(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
bound := boundSchemaCommandsForHelpFlagTest(t, root)
|
||||
tools := embeddedSchemaAllToolsForHelpFlagTest(t, root)
|
||||
assertSchemaParametersMatchExecutableHelpFlags(t, bound, tools, "embedded schema --all")
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, root)
|
||||
assertSchemaParametersMatchExecutableHelpFlags(t, bound, tools, "delivery schema --all")
|
||||
}
|
||||
|
||||
func boundSchemaCommandsForHelpFlagTest(t testing.TB, root *cobra.Command) cli.BoundCommandRegistry {
|
||||
@@ -55,14 +55,14 @@ func boundSchemaCommandsForHelpFlagTest(t testing.TB, root *cobra.Command) cli.B
|
||||
return bound
|
||||
}
|
||||
|
||||
func embeddedSchemaAllToolsForHelpFlagTest(t testing.TB, root *cobra.Command) map[string]map[string]any {
|
||||
func deliverySchemaAllToolsForHelpFlagTest(t testing.TB, root *cobra.Command) map[string]map[string]any {
|
||||
t.Helper()
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", "--all", "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute embedded schema --all: %v; stderr=%s", err, stderr.String())
|
||||
t.Fatalf("execute delivery schema --all: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
var payload struct {
|
||||
Products []struct {
|
||||
@@ -70,23 +70,23 @@ func embeddedSchemaAllToolsForHelpFlagTest(t testing.TB, root *cobra.Command) ma
|
||||
} `json:"products"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode embedded schema --all: %v", err)
|
||||
t.Fatalf("decode delivery schema --all: %v", err)
|
||||
}
|
||||
tools := make(map[string]map[string]any)
|
||||
for _, product := range payload.Products {
|
||||
for _, tool := range product.Tools {
|
||||
canonical := strings.TrimSpace(schemaContractString(tool["canonical_path"]))
|
||||
if canonical == "" {
|
||||
t.Fatal("embedded schema --all contains an empty canonical path")
|
||||
t.Fatal("delivery schema --all contains an empty canonical path")
|
||||
}
|
||||
if _, exists := tools[canonical]; exists {
|
||||
t.Fatalf("embedded schema --all contains duplicate canonical %q", canonical)
|
||||
t.Fatalf("delivery schema --all contains duplicate canonical %q", canonical)
|
||||
}
|
||||
tools[canonical] = tool
|
||||
}
|
||||
}
|
||||
if len(tools) == 0 {
|
||||
t.Fatal("embedded schema --all contains no tools")
|
||||
t.Fatal("delivery schema --all contains no tools")
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
// TestCollectedIdentityIsValidSingleSource is the standing gate for the
|
||||
// retired reviewed registry: command identity collected from live Cobra
|
||||
// leaves carrying ContractFinal.Identity must be a valid single source for
|
||||
// EffectiveCommandRegistry assembly. It asserts the collector returns a
|
||||
// non-empty spec set with no missing primaries, builds an effective registry
|
||||
// from it, and produces a stable SourceHash across repeated collection walks.
|
||||
func TestCrossPlatformCoverageCollectedIdentityIsValidSingleSource(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
collected, report, err := cli.CollectIdentitySpecs(root)
|
||||
if err != nil {
|
||||
t.Fatalf("collect identity specs: %v", err)
|
||||
}
|
||||
if len(collected) == 0 {
|
||||
t.Fatal("identity collection returned no command specs")
|
||||
}
|
||||
if len(report.MissingPrimary) != 0 {
|
||||
t.Fatalf("identity collection reported missing primaries: %v", report.MissingPrimary)
|
||||
}
|
||||
|
||||
t.Logf("walk leaves=%d withIdentity=%d hiddenPrimaries=%d excluded=%d noIdentity=%d collected=%d",
|
||||
report.Leaves, report.WithIdentity, report.HiddenPrimaries, report.Excluded, len(report.NoIdentity), len(collected))
|
||||
for _, leaf := range report.NoIdentity {
|
||||
t.Logf("NO_IDENTITY_LEAF %s", leaf)
|
||||
}
|
||||
|
||||
effective, err := cli.BuildEffectiveFromSpecs(collected)
|
||||
if err != nil {
|
||||
t.Fatalf("build effective registry from collected specs: %v", err)
|
||||
}
|
||||
sourceHash := effective.SourceHash()
|
||||
t.Logf("collected SourceHash=%s commands=%d", sourceHash, len(effective.Commands))
|
||||
if sourceHash == "" {
|
||||
t.Fatal("effective registry SourceHash is empty")
|
||||
}
|
||||
|
||||
collectedAgain, _, err := cli.CollectIdentitySpecs(root)
|
||||
if err != nil {
|
||||
t.Fatalf("re-collect identity specs: %v", err)
|
||||
}
|
||||
effectiveAgain, err := cli.BuildEffectiveFromSpecs(collectedAgain)
|
||||
if err != nil {
|
||||
t.Fatalf("rebuild effective registry from collected specs: %v", err)
|
||||
}
|
||||
if got := effectiveAgain.SourceHash(); got != sourceHash {
|
||||
t.Fatalf("collected identity SourceHash is not stable across walks: %q vs %q", got, sourceHash)
|
||||
}
|
||||
}
|
||||
@@ -5,74 +5,61 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
|
||||
type interfaceCase struct {
|
||||
canonical string
|
||||
mode string
|
||||
reason string
|
||||
sourceSuffix string
|
||||
canonical string
|
||||
mode string
|
||||
reason string
|
||||
}
|
||||
tests := []interfaceCase{
|
||||
{
|
||||
canonical: "attendance.get_attendance_summary",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls attendance-wukong/get_user_attendance_summary, which is absent from the pinned MCP metadata snapshot; the incompatible attendance/get_attendance_summary contract must not be advertised.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/attendance.json",
|
||||
canonical: "attendance.get_attendance_summary",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls attendance-wukong/get_user_attendance_summary, which is absent from the pinned MCP metadata snapshot; the incompatible attendance/get_attendance_summary contract must not be advertised.",
|
||||
},
|
||||
{
|
||||
canonical: "drive.list_files",
|
||||
mode: "composite",
|
||||
reason: "The CLI command routes by --workspace between drive/list_files and doc/list_nodes, so the reviewed executable wrapper has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/drive.json",
|
||||
canonical: "drive.list_files",
|
||||
mode: "composite",
|
||||
reason: "The CLI command routes by --workspace between drive/list_files and doc/list_nodes, so the reviewed executable wrapper has no single direct MCP interface.",
|
||||
},
|
||||
{
|
||||
canonical: "chat.search_groups",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls im/search_groups with a flat payload, while the pinned snapshot only contains the incompatible chat/search_groups_by_keyword contract.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/chat.json",
|
||||
canonical: "chat.search_groups",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls im/search_groups with a flat payload, while the pinned snapshot only contains the incompatible chat/search_groups_by_keyword contract.",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.range_batch_set_style",
|
||||
mode: "composite",
|
||||
reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/sheet.json",
|
||||
canonical: "sheet.range_batch_set_style",
|
||||
mode: "composite",
|
||||
reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.range_read",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls sheet/get_cell_infos, which is absent from the pinned MCP metadata snapshot; the incompatible sheet/get_range contract must not be advertised.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/sheet.json",
|
||||
canonical: "sheet.range_read",
|
||||
mode: "composite",
|
||||
reason: "Reviewed unpinned remote adapter: the CLI calls sheet/get_cell_infos, which is absent from the pinned MCP metadata snapshot; the incompatible sheet/get_range contract must not be advertised.",
|
||||
},
|
||||
{
|
||||
canonical: "wiki.list_wikiSpaces",
|
||||
mode: "composite",
|
||||
reason: "The CLI command routes by --type between wiki/list_wikiSpaces and drive/list_spaces, so the reviewed executable wrapper has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/wiki.json",
|
||||
canonical: "wiki.list_wikiSpaces",
|
||||
mode: "composite",
|
||||
reason: "The CLI command routes by --type between wiki/list_wikiSpaces and drive/list_spaces, so the reviewed executable wrapper has no single direct MCP interface.",
|
||||
},
|
||||
{
|
||||
canonical: "event.consume",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/event.json",
|
||||
canonical: "event.consume",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
|
||||
},
|
||||
{
|
||||
canonical: "event.status",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command reads the remote personal-event subscription control plane and combines it with local bus and consumer state; no single pinned RPC represents the result.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/event.json",
|
||||
canonical: "event.status",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command reads the remote personal-event subscription control plane and combines it with local bus and consumer state; no single pinned RPC represents the result.",
|
||||
},
|
||||
{
|
||||
canonical: "event.stop",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command deletes remote personal-event subscriptions, interrupts local consumers, updates local state, and may stop the local bus; no single pinned RPC represents the workflow.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/event.json",
|
||||
canonical: "event.stop",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command deletes remote personal-event subscriptions, interrupts local consumers, updates local state, and may stop the local bus; no single pinned RPC represents the workflow.",
|
||||
},
|
||||
}
|
||||
for _, canonical := range []string{
|
||||
@@ -82,10 +69,9 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
|
||||
"aitable.view_update_timebar",
|
||||
} {
|
||||
tests = append(tests, interfaceCase{
|
||||
canonical: canonical,
|
||||
mode: "composite",
|
||||
reason: "The CLI performs an aitable/get_views preflight, locally transforms the requested configuration, and then calls aitable/update_view; the two-call workflow has no single direct MCP interface.",
|
||||
sourceSuffix: "internal/cli/schema_hints/metadata/aitable.json",
|
||||
canonical: canonical,
|
||||
mode: "composite",
|
||||
reason: "The CLI performs an aitable/get_views preflight, locally transforms the requested configuration, and then calls aitable/update_view; the two-call workflow has no single direct MCP interface.",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -119,11 +105,11 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
|
||||
t.Errorf("missing %s provenance", field)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(entry["precedence"]); got != "reviewed_explicit" {
|
||||
t.Errorf("%s provenance precedence = %q, want reviewed_explicit", field, got)
|
||||
if got := schemaContractString(entry["precedence"]); got != "contract_final" {
|
||||
t.Errorf("%s provenance precedence = %q, want contract_final", field, got)
|
||||
}
|
||||
if got := schemaContractString(entry["source"]); !strings.HasSuffix(got, test.sourceSuffix) {
|
||||
t.Errorf("%s provenance source = %q, want suffix %q", field, got, test.sourceSuffix)
|
||||
if got := schemaContractString(entry["source"]); got != "corecmd.contract" {
|
||||
t.Errorf("%s provenance source = %q, want corecmd.contract", field, got)
|
||||
}
|
||||
}
|
||||
if got := provenance["interface_ref"]["value"]; got != nil {
|
||||
@@ -174,151 +160,81 @@ func TestViewGetWrappersUsePinnedGetViewsInterface(t *testing.T) {
|
||||
provenance := schemaContractMap(tool["field_provenance"])
|
||||
for _, field := range []string{"interface_mode", "availability", "interface_ref"} {
|
||||
entry := provenance[field]
|
||||
if got := schemaContractString(entry["precedence"]); got != "reviewed_explicit" {
|
||||
t.Errorf("%s %s precedence = %q, want reviewed_explicit", canonical, field, got)
|
||||
if got := schemaContractString(entry["precedence"]); got != "contract_final" {
|
||||
t.Errorf("%s %s precedence = %q, want contract_final", canonical, field, got)
|
||||
}
|
||||
if got := schemaContractString(entry["source"]); !strings.Contains(got, "internal/cli/schema_hints/metadata/") {
|
||||
t.Errorf("%s %s source = %q, want reviewed interface disposition source", canonical, field, got)
|
||||
if got := schemaContractString(entry["source"]); got != "corecmd.contract" {
|
||||
t.Errorf("%s %s source = %q, want corecmd.contract", canonical, field, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReviewedInterfaceDispositionSourceOwnsRuntimeSurface asserts that every
|
||||
// delivered Catalog tool owns interface disposition via ContractFinal
|
||||
// (corecmd.contract). Former schema_hints audit JSON
|
||||
// (runtime-surface-completeness / zz-interface-disposition-review) is retired.
|
||||
func TestReviewedInterfaceDispositionSourceOwnsRuntimeSurface(t *testing.T) {
|
||||
type hintFile struct {
|
||||
Source map[string]any `json:"source"`
|
||||
Tools map[string]map[string]any `json:"tools"`
|
||||
}
|
||||
load := func(path string) hintFile {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var value hintFile
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
runtimeSurface := load("../cli/schema_hints/runtime-surface-completeness.json")
|
||||
legacyDispositionKeys := load("../cli/schema_hints/zz-interface-disposition-review.json").Tools
|
||||
dispositions := hintFile{Source: map[string]any{"reviewed": true}, Tools: map[string]map[string]any{}}
|
||||
for _, product := range []string{
|
||||
"attendance", "aitable", "chat", "drive", "event", "sheet", "wiki", "doc", "mail", "todo", "calendar", "conference", "contact", "dev", "devdoc", "ding", "live", "minutes", "oa", "pat", "report", "aisearch",
|
||||
} {
|
||||
path := "../cli/schema_hints/metadata/" + product + ".json"
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
file := load(path)
|
||||
for canonical, hint := range file.Tools {
|
||||
if _, ok := legacyDispositionKeys[canonical]; !ok {
|
||||
continue
|
||||
}
|
||||
trimmed := map[string]any{}
|
||||
for _, field := range []string{"interface_mode", "availability", "interface_ref", "interface_reason"} {
|
||||
if value, exists := hint[field]; exists {
|
||||
trimmed[field] = value
|
||||
}
|
||||
}
|
||||
dispositions.Tools[canonical] = trimmed
|
||||
}
|
||||
}
|
||||
if dispositions.Source["reviewed"] != true {
|
||||
t.Fatalf("interface disposition source reviewed = %#v, want true", dispositions.Source["reviewed"])
|
||||
}
|
||||
for canonical, hint := range runtimeSurface.Tools {
|
||||
if hint["reviewed"] != false {
|
||||
t.Errorf("%s runtime surface reviewed = %#v, want false", canonical, hint["reviewed"])
|
||||
}
|
||||
for _, field := range []string{"interface_mode", "availability", "interface_ref", "interface_reason"} {
|
||||
if _, exists := hint[field]; exists {
|
||||
t.Errorf("%s runtime surface still owns %s", canonical, field)
|
||||
}
|
||||
}
|
||||
if _, exists := dispositions.Tools[canonical]; !exists {
|
||||
t.Errorf("%s runtime surface has no reviewed interface disposition", canonical)
|
||||
}
|
||||
}
|
||||
|
||||
allowedFields := map[string]bool{
|
||||
"interface_mode": true,
|
||||
"availability": true,
|
||||
"interface_ref": true,
|
||||
"interface_reason": true,
|
||||
}
|
||||
canonicals := make([]string, 0, len(dispositions.Tools))
|
||||
for canonical, hint := range dispositions.Tools {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
canonicals := make([]string, 0, len(tools))
|
||||
for canonical := range tools {
|
||||
canonicals = append(canonicals, canonical)
|
||||
for field := range hint {
|
||||
if !allowedFields[field] {
|
||||
t.Errorf("%s interface-only source contains non-interface field %s", canonical, field)
|
||||
}
|
||||
}
|
||||
mode := schemaContractString(hint["interface_mode"])
|
||||
if mode == "local" {
|
||||
t.Errorf("%s remote interface review is incorrectly classified local", canonical)
|
||||
}
|
||||
if schemaContractString(hint["availability"]) != "available" {
|
||||
t.Errorf("%s reviewed disposition is not available", canonical)
|
||||
}
|
||||
switch mode {
|
||||
case "mcp":
|
||||
ref := schemaInterfaceObject(hint["interface_ref"])
|
||||
if schemaContractString(ref["product_id"]) == "" || schemaContractString(ref["rpc_name"]) == "" {
|
||||
t.Errorf("%s reviewed mcp disposition has no complete interface_ref", canonical)
|
||||
}
|
||||
case "composite":
|
||||
if hint["interface_ref"] != nil {
|
||||
t.Errorf("%s reviewed composite disposition advertises interface_ref %#v", canonical, hint["interface_ref"])
|
||||
}
|
||||
if schemaContractString(hint["interface_reason"]) == "" {
|
||||
t.Errorf("%s reviewed composite disposition has no reason", canonical)
|
||||
}
|
||||
default:
|
||||
t.Errorf("%s reviewed disposition mode = %q", canonical, mode)
|
||||
}
|
||||
}
|
||||
sort.Strings(canonicals)
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), canonicals...)
|
||||
if len(canonicals) == 0 {
|
||||
t.Fatal("delivery schema --all contains no tools")
|
||||
}
|
||||
for _, canonical := range canonicals {
|
||||
want := dispositions.Tools[canonical]
|
||||
tool := payload.Tools[canonical]
|
||||
if got := schemaContractString(tool["interface_mode"]); got != schemaContractString(want["interface_mode"]) {
|
||||
t.Errorf("%s final interface_mode = %q, want %q", canonical, got, want["interface_mode"])
|
||||
tool := tools[canonical]
|
||||
mode := schemaContractString(tool["interface_mode"])
|
||||
availability := schemaContractString(tool["availability"])
|
||||
switch mode {
|
||||
case "mcp", "composite", "local":
|
||||
default:
|
||||
t.Errorf("%s interface_mode = %q, want mcp|composite|local", canonical, mode)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != schemaContractString(want["availability"]) {
|
||||
t.Errorf("%s final availability = %q, want %q", canonical, got, want["availability"])
|
||||
switch availability {
|
||||
case "available", "unavailable":
|
||||
default:
|
||||
t.Errorf("%s availability = %q, want available|unavailable", canonical, availability)
|
||||
}
|
||||
if schemaContractString(want["interface_mode"]) == "composite" {
|
||||
switch {
|
||||
case availability == "unavailable":
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("%s final composite interface_ref = %#v, want nil", canonical, tool["interface_ref"])
|
||||
t.Errorf("%s unavailable interface_ref = %#v, want nil", canonical, tool["interface_ref"])
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got != schemaContractString(want["interface_reason"]) {
|
||||
t.Errorf("%s final interface_reason = %q, want %q", canonical, got, want["interface_reason"])
|
||||
if schemaContractString(tool["interface_reason"]) == "" {
|
||||
t.Errorf("%s unavailable disposition missing interface_reason", canonical)
|
||||
}
|
||||
} else {
|
||||
gotRef := schemaInterfaceObject(tool["interface_ref"])
|
||||
wantRef := schemaInterfaceObject(want["interface_ref"])
|
||||
for _, field := range []string{"product_id", "rpc_name"} {
|
||||
if got := schemaContractString(gotRef[field]); got != schemaContractString(wantRef[field]) {
|
||||
t.Errorf("%s final interface_ref.%s = %q, want %q", canonical, field, got, wantRef[field])
|
||||
}
|
||||
case mode == "mcp":
|
||||
ref := schemaInterfaceObject(tool["interface_ref"])
|
||||
if schemaContractString(ref["product_id"]) == "" || schemaContractString(ref["rpc_name"]) == "" {
|
||||
t.Errorf("%s mcp disposition has incomplete interface_ref", canonical)
|
||||
}
|
||||
case mode == "composite":
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("%s composite interface_ref = %#v, want nil", canonical, tool["interface_ref"])
|
||||
}
|
||||
if schemaContractString(tool["interface_reason"]) == "" {
|
||||
t.Errorf("%s composite disposition missing interface_reason", canonical)
|
||||
}
|
||||
case mode == "local":
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("%s local interface_ref = %#v, want nil", canonical, tool["interface_ref"])
|
||||
}
|
||||
}
|
||||
provenance := schemaContractMap(tool["field_provenance"])
|
||||
fields := []string{"interface_mode", "availability", "interface_ref"}
|
||||
if schemaContractString(want["interface_mode"]) == "composite" {
|
||||
if mode == "composite" || availability == "unavailable" {
|
||||
fields = append(fields, "interface_reason")
|
||||
}
|
||||
for _, field := range fields {
|
||||
entry := provenance[field]
|
||||
if got := schemaContractString(entry["precedence"]); got != "reviewed_explicit" {
|
||||
t.Errorf("%s final %s precedence = %q, want reviewed_explicit", canonical, field, got)
|
||||
if got := schemaContractString(entry["precedence"]); got != "contract_final" {
|
||||
t.Errorf("%s %s precedence = %q, want contract_final", canonical, field, got)
|
||||
}
|
||||
if got := schemaContractString(entry["source"]); !strings.Contains(got, "internal/cli/schema_hints/metadata/") {
|
||||
t.Errorf("%s final %s source = %q, want reviewed disposition source", canonical, field, got)
|
||||
if got := schemaContractString(entry["source"]); got != "corecmd.contract" {
|
||||
t.Errorf("%s %s source = %q, want corecmd.contract", canonical, field, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
const schemaResolveMetaCacheChildEnv = "DWS_SCHEMA_RESOLVE_META_CACHE_CHILD"
|
||||
|
||||
// TestResolveMetaAndLeafHelpReuseAssembledMetaCache proves production
|
||||
// RegisterSchemaSourceRoot → delivery Once caches CommandMeta: first
|
||||
// ResolveMeta pays assembly once; subsequent ResolveMeta and leaf --help
|
||||
// Safety do not increment the Catalog counter.
|
||||
func TestResolveMetaAndLeafHelpReuseAssembledMetaCache(t *testing.T) {
|
||||
if os.Getenv(schemaResolveMetaCacheChildEnv) == "1" {
|
||||
registerSchemaRuntimeDelivery()
|
||||
|
||||
counts := cli.RuntimeSchemaMetadataLoadCounts()
|
||||
if counts.Catalog != 0 || counts.MetaIndex != 0 {
|
||||
t.Fatalf("precondition Catalog/MetaIndex = %#v", counts)
|
||||
}
|
||||
|
||||
meta, ok := cli.ResolveMeta("dev app delete")
|
||||
if !ok || meta.Identity.Canonical == "" {
|
||||
t.Fatalf("ResolveMeta(dev app delete) = %#v ok=%v", meta, ok)
|
||||
}
|
||||
counts = cli.RuntimeSchemaMetadataLoadCounts()
|
||||
if counts.Catalog != 1 || counts.MetaIndex != 1 {
|
||||
t.Fatalf("after first ResolveMeta counts = %#v, want Catalog=1 MetaIndex=1", counts)
|
||||
}
|
||||
|
||||
for range 4 {
|
||||
if _, ok := cli.ResolveMeta("dev app delete"); !ok {
|
||||
t.Fatal("steady ResolveMeta ok=false")
|
||||
}
|
||||
}
|
||||
counts = cli.RuntimeSchemaMetadataLoadCounts()
|
||||
if counts.Catalog != 1 || counts.MetaIndex != 1 {
|
||||
t.Fatalf("after steady ResolveMeta counts = %#v", counts)
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
var helpOut bytes.Buffer
|
||||
root.SetOut(&helpOut)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"dev", "app", "delete", "--help"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("dev app delete --help: %v", err)
|
||||
}
|
||||
if meta.Safety.ShouldRender() && !strings.Contains(helpOut.String(), "Safety:") {
|
||||
t.Fatalf("leaf --help missing Safety annotation; output=%q", helpOut.String())
|
||||
}
|
||||
counts = cli.RuntimeSchemaMetadataLoadCounts()
|
||||
if counts.Catalog != 1 || counts.MetaIndex != 1 {
|
||||
t.Fatalf("leaf --help re-assembled Schema: %#v", counts)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestResolveMetaAndLeafHelpReuseAssembledMetaCache$", "-test.count=1")
|
||||
command.Env = append(os.Environ(), schemaResolveMetaCacheChildEnv+"=1")
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMeta cache child failed: %v\n%s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
}
|
||||
@@ -15,45 +15,54 @@ type finalSchemaSafetyWant struct {
|
||||
risk string
|
||||
confirmation string
|
||||
idempotency string
|
||||
// provenance is the expected field_provenance precedence; empty defaults
|
||||
// to "contract_final" (DeclareLeafMetadata / corecmd.Contract). Production
|
||||
// metadata shells no longer carry reviewed tool rows.
|
||||
provenance string
|
||||
}
|
||||
|
||||
func TestReviewedMutationSafetyReachesFinalSchema(t *testing.T) {
|
||||
const declared = "contract_final"
|
||||
wants := []finalSchemaSafetyWant{
|
||||
{canonical: "aitable.form_field_hide", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "idempotent"},
|
||||
{canonical: "chat.dismiss_group", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "drive.recycle_restore", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
|
||||
{canonical: "minutes.create_speaker_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.clear_range", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.batch_update", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.range_batch_clear", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.group_dimension", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.sort_filter", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
|
||||
{canonical: "sheet.ungroup_dimension", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown"},
|
||||
{canonical: "aitable.form_field_hide", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "idempotent", provenance: declared},
|
||||
{canonical: "chat.dismiss_group", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "drive.recycle_restore", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "minutes.create_speaker_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "sheet.clear_range", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "sheet.batch_update", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "sheet.range_batch_clear", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "sheet.group_dimension", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "sheet.sort_filter", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "sheet.ungroup_dimension", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
|
||||
}
|
||||
assertFinalSchemaSafety(t, wants)
|
||||
}
|
||||
|
||||
func TestDevAppWriteGuardRequiresFinalSchemaConfirmation(t *testing.T) {
|
||||
// devapp 全树已声明化:provenance 为 contract_final;effect/risk 逐字
|
||||
// 保持 merge-base 评审值(写操作一律 high,publish 为 write/high),
|
||||
// 重分级需要独立的契约变更 PR。
|
||||
const declared = "contract_final"
|
||||
wants := []finalSchemaSafetyWant{
|
||||
{canonical: "dev.add_dev_app_members", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.apply_dev_app_permissions", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.create_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.create_dev_app_version", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.delete_dev_app", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.disable_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.disable_dev_app_robot", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.enable_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.enable_dev_app_robot", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.publish_dev_app_version", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.remove_dev_app_members", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.remove_dev_app_permissions", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.set_extension_robot_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.set_extension_webapp_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.submit_robot_create_task", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.subscribe_dev_app_events", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.unsubscribe_dev_app_events", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.update_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.update_dev_app_security_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown"},
|
||||
{canonical: "dev.add_dev_app_members", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.apply_dev_app_permissions", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.create_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.create_dev_app_version", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.delete_dev_app", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.disable_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.disable_dev_app_robot", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.enable_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.enable_dev_app_robot", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.publish_dev_app_version", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.remove_dev_app_members", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.remove_dev_app_permissions", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.set_extension_robot_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.set_extension_webapp_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.submit_robot_create_task", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.subscribe_dev_app_events", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.unsubscribe_dev_app_events", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.update_dev_app", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "dev.update_dev_app_security_config", effect: "write", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
}
|
||||
assertFinalSchemaSafety(t, wants)
|
||||
}
|
||||
@@ -77,13 +86,17 @@ func assertFinalSchemaSafety(t *testing.T, wants []finalSchemaSafetyWant) {
|
||||
"confirmation": want.confirmation,
|
||||
"idempotency": want.idempotency,
|
||||
}
|
||||
wantProvenance := want.provenance
|
||||
if wantProvenance == "" {
|
||||
wantProvenance = "contract_final"
|
||||
}
|
||||
provenance := schemaContractMap(tool["field_provenance"])
|
||||
for field, expected := range values {
|
||||
if got := schemaContractString(tool[field]); got != expected {
|
||||
t.Errorf("%s = %q, want %q", field, got, expected)
|
||||
}
|
||||
if got := schemaContractString(provenance[field]["precedence"]); got != "reviewed_explicit" {
|
||||
t.Errorf("%s provenance precedence = %q, want reviewed_explicit", field, got)
|
||||
if got := schemaContractString(provenance[field]["precedence"]); got != wantProvenance {
|
||||
t.Errorf("%s provenance precedence = %q, want %s", field, got, wantProvenance)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -16,12 +16,16 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 265
|
||||
schemaPublishedShortcutCount = 215
|
||||
publicShortcutCount = 266
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||
schemaPublishedShortcutCount = 216
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 215
|
||||
)
|
||||
|
||||
func TestEmbeddedSchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
tools := embeddedSchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
public := make([]shortcut.Shortcut, 0, publicShortcutCount)
|
||||
for _, candidate := range shortcut.All() {
|
||||
if candidate.UserDefined || !shortcut.InPublicCatalog(candidate.Service, candidate.Command) {
|
||||
@@ -40,10 +44,10 @@ func TestEmbeddedSchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *tes
|
||||
}
|
||||
}
|
||||
if deliveredShortcuts != schemaPublishedShortcutCount {
|
||||
t.Fatalf("embedded schema --all shortcut tools = %d, want %d", deliveredShortcuts, schemaPublishedShortcutCount)
|
||||
t.Fatalf("delivery schema --all shortcut tools = %d, want %d", deliveredShortcuts, schemaPublishedShortcutCount)
|
||||
}
|
||||
|
||||
exclusions, err := cli.EmbeddedRuntimeSchemaExclusions()
|
||||
exclusions, err := cli.ReviewedRuntimeSchemaExclusions()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -64,23 +68,23 @@ func TestEmbeddedSchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *tes
|
||||
if tool == nil {
|
||||
cliPath := declared.Service + " " + declared.Command
|
||||
if !excludedPaths[cliPath] {
|
||||
t.Fatalf("embedded schema --all is missing %s (%s) without an exact reviewed exclusion", canonical, cliPath)
|
||||
t.Fatalf("delivery schema --all is missing %s (%s) without an exact reviewed exclusion", canonical, cliPath)
|
||||
}
|
||||
excludedShortcuts++
|
||||
return
|
||||
}
|
||||
assertEmbeddedShortcutIdentityAndSelection(t, tool, declared, canonical)
|
||||
assertEmbeddedShortcutSafetyAndInterface(t, tool, declared, canonical)
|
||||
assertEmbeddedShortcutParameters(t, tool, declared, canonical)
|
||||
assertEmbeddedShortcutConstraints(t, tool, declared, canonical)
|
||||
assertDeliveryShortcutIdentityAndSelection(t, tool, declared, canonical)
|
||||
assertDeliveryShortcutSafetyAndInterface(t, tool, declared, canonical)
|
||||
assertDeliveryShortcutParameters(t, tool, declared, canonical)
|
||||
assertDeliveryShortcutConstraints(t, tool, declared, canonical)
|
||||
})
|
||||
}
|
||||
if got, want := excludedShortcuts, publicShortcutCount-schemaPublishedShortcutCount; got != want {
|
||||
if got, want := excludedShortcuts, publicShortcutCount-publiclyDeliveredShortcutCount; got != want {
|
||||
t.Fatalf("exactly excluded public shortcuts = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) {
|
||||
func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "chat +messages-read-status")
|
||||
if got, want := schemaContractString(leaf["canonical_path"]), "chat.shortcut_messages_read_status"; got != want {
|
||||
t.Fatalf("shortcut leaf canonical_path = %q, want %q", got, want)
|
||||
@@ -89,11 +93,15 @@ func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
t.Fatalf("shortcut leaf confirmation = %q, want %q", got, want)
|
||||
}
|
||||
conversationID := schemaContractMap(leaf["parameters"])["conversation-id"]
|
||||
if required, _ := conversationID["required"].(bool); !required {
|
||||
t.Fatal("public --conversation-id must become required after hidden compatibility aliases are removed from Schema")
|
||||
if required, _ := conversationID["required"].(bool); required {
|
||||
t.Fatal("public --conversation-id must stay optional when hidden siblings still satisfy the declared exactly_one group")
|
||||
}
|
||||
if got := leaf["constraints"]; got != nil {
|
||||
t.Fatalf("shortcut leaf constraints = %#v, want omitted after hidden compatibility aliases collapse", got)
|
||||
wantMessagesConstraints := map[string]any{
|
||||
"require_one_of": [][]string{{"conversation-id", "group", "id"}},
|
||||
"mutually_exclusive": [][]string{{"conversation-id", "group", "id"}},
|
||||
}
|
||||
if got := leaf["constraints"]; !schemaContractJSONEqual(got, wantMessagesConstraints) {
|
||||
t.Fatalf("shortcut leaf constraints = %#v, want %#v", got, wantMessagesConstraints)
|
||||
}
|
||||
|
||||
constrainedLeaf := executeShortcutSchemaQuery(t, "--cli-path", "calendar +freebusy")
|
||||
@@ -143,7 +151,7 @@ func shortcutSchemaCanonical(declared shortcut.Shortcut) string {
|
||||
return declared.Service + ".shortcut_" + name
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutIdentityAndSelection(
|
||||
func assertDeliveryShortcutIdentityAndSelection(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
@@ -179,7 +187,7 @@ func assertEmbeddedShortcutIdentityAndSelection(
|
||||
}
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutSafetyAndInterface(
|
||||
func assertDeliveryShortcutSafetyAndInterface(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
@@ -214,7 +222,7 @@ func assertEmbeddedShortcutSafetyAndInterface(
|
||||
}
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutParameters(
|
||||
func assertDeliveryShortcutParameters(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
@@ -308,14 +316,20 @@ func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
|
||||
visible = append(visible, constrained)
|
||||
}
|
||||
}
|
||||
if len(visible) == 1 && visible[0] == flagName {
|
||||
// Match AnnotateConstraints: only collapse to required when the projected
|
||||
// group has a single member (no remaining hidden siblings).
|
||||
flags := visible
|
||||
if len(visible) < len(constraint.Flags) {
|
||||
flags = append([]string(nil), constraint.Flags...)
|
||||
}
|
||||
if len(flags) == 1 && flags[0] == flagName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertEmbeddedShortcutConstraints(
|
||||
func assertDeliveryShortcutConstraints(
|
||||
t testing.TB,
|
||||
tool map[string]any,
|
||||
declared shortcut.Shortcut,
|
||||
@@ -330,12 +344,18 @@ func assertEmbeddedShortcutConstraints(
|
||||
}
|
||||
want := map[string][][]string{}
|
||||
for _, constraint := range declared.Constraints {
|
||||
flags := make([]string, 0, len(constraint.Flags))
|
||||
visible := make([]string, 0, len(constraint.Flags))
|
||||
for _, flagName := range constraint.Flags {
|
||||
if public[flagName] {
|
||||
flags = append(flags, flagName)
|
||||
visible = append(visible, flagName)
|
||||
}
|
||||
}
|
||||
// Match AnnotateConstraints declare≡execute projection: keep the full
|
||||
// declared group when any hidden sibling remains.
|
||||
flags := visible
|
||||
if len(visible) < len(constraint.Flags) {
|
||||
flags = append([]string(nil), constraint.Flags...)
|
||||
}
|
||||
switch constraint.Kind {
|
||||
case shortcut.ConstraintAtLeastOne:
|
||||
if len(flags) > 1 {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var registerSchemaRuntimeDeliveryOnce sync.Once
|
||||
|
||||
// registerSchemaRuntimeDelivery installs the ResolveSchemaBuild root factory
|
||||
// for production Catalog / ResolveMeta delivery. Called from NewRootCommand
|
||||
// (and optionally cmd entrypoints). Intentionally NOT an init() side effect:
|
||||
// importing app from package cli_test must not flip package-cli tests onto
|
||||
// the assembly path.
|
||||
func registerSchemaRuntimeDelivery() {
|
||||
registerSchemaRuntimeDeliveryOnce.Do(func() {
|
||||
cli.RegisterSchemaSourceRoot(func() *cobra.Command {
|
||||
return NewSchemaSourceRootCommand()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageRegisterSchemaRuntimeDelivery covers the production
|
||||
// RegisterSchemaSourceRoot install path used by NewRootCommand.
|
||||
func TestCrossPlatformCoverageRegisterSchemaRuntimeDelivery(t *testing.T) {
|
||||
registerSchemaRuntimeDelivery()
|
||||
if !cli.SchemaSourceRootRegistered() {
|
||||
t.Fatal("registerSchemaRuntimeDelivery did not install Schema source root")
|
||||
}
|
||||
meta, ok := cli.ResolveMeta("dev app delete")
|
||||
if !ok || meta.Identity.Canonical == "" {
|
||||
t.Fatalf("ResolveMeta after registerSchemaRuntimeDelivery = %#v ok=%v", meta, ok)
|
||||
}
|
||||
safety, ok := cli.SafetyForCLIPath("dev app delete")
|
||||
if !ok || safety.Effect == "" {
|
||||
t.Fatalf("SafetyForCLIPath after register = %#v ok=%v", safety, ok)
|
||||
}
|
||||
// Idempotent: Once must not panic or clear the factory.
|
||||
registerSchemaRuntimeDelivery()
|
||||
if !cli.SchemaSourceRootRegistered() {
|
||||
t.Fatal("second registerSchemaRuntimeDelivery cleared Schema source root")
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,12 @@ func TestTodoListAttachmentDeliveredSchemaMatchesExecutableHelp(t *testing.T) {
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", cliPath, "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute embedded schema leaf: %v; stderr=%s", err, stderr.String())
|
||||
t.Fatalf("execute delivery schema leaf: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
|
||||
var tool map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &tool); err != nil {
|
||||
t.Fatalf("decode embedded schema leaf: %v", err)
|
||||
t.Fatalf("decode delivery schema leaf: %v", err)
|
||||
}
|
||||
if got := schemaContractString(tool["canonical_path"]); got != canonicalPath {
|
||||
t.Fatalf("canonical_path = %q, want %q", got, canonicalPath)
|
||||
|
||||
@@ -110,7 +110,7 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
|
||||
if err := runSkillAdd(cmd, []string{"id", "target"}); err == nil {
|
||||
t.Fatal("invalid skill target should fail")
|
||||
}
|
||||
skillResolveTargetPath = func(string) (string, error) { return "dest", nil }
|
||||
skillResolveTargetPath = func(string) (string, error) { return filepath.Join(t.TempDir(), "dest"), nil }
|
||||
skillLoadAccessToken = func(context.Context) (string, error) { return "", fail }
|
||||
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
||||
t.Fatalf("skill add auth error = %v", err)
|
||||
@@ -280,7 +280,7 @@ func TestCrossPlatformCoverageSkillCommandLowLevelRemainingCoverage(t *testing.T
|
||||
}
|
||||
|
||||
skillMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
if err := extractSkillZip("missing", "dest"); err == nil {
|
||||
if err := extractSkillZip("missing", filepath.Join(t.TempDir(), "dest")); err == nil {
|
||||
t.Fatal("zip destination mkdir failure should propagate")
|
||||
}
|
||||
zipPath := filepath.Join(t.TempDir(), "files.zip")
|
||||
|
||||
+127
-41
@@ -75,15 +75,14 @@ func newSkillSetupCommand() *cobra.Command {
|
||||
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
|
||||
|
||||
支持两种模式:
|
||||
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
|
||||
multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
|
||||
尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
|
||||
生产前请评估,问题请提 issue 反馈
|
||||
multi 多 skill(默认)—— 按产品拆 N 个独立 skill(dingtalk-*)
|
||||
mono 单 skill(legacy)—— 总入口 SKILL.md + references/products/
|
||||
|
||||
multi 模式支持按产品挑选:
|
||||
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
|
||||
-x/--exclude 从全装里剔除指定子 skill(可重复,与 --skill 互斥)
|
||||
未列出的已有 dingtalk-* skill 会保留(additive 叠加语义)
|
||||
用 -s/-x 挑选时未列出的已有 dingtalk-* skill 会保留(additive 叠加语义);
|
||||
不带过滤条件的全量安装会清理不在 bundle 内的过期 dingtalk-* / dws-shared。
|
||||
|
||||
不带 --mode 时进入交互式询问;不带 --target 时铺到所有检测到的 Agent 目录。
|
||||
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
|
||||
@@ -168,8 +167,13 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude
|
||||
// 时保持 additive(不动未列出的 sibling);全量安装与 install.sh /
|
||||
// install.js 对齐,清掉不在 bundle 内的过期 dingtalk-* / dws-shared。
|
||||
filtered := len(includeRaw) > 0 || len(excludeRaw) > 0
|
||||
|
||||
if !autoYes {
|
||||
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames)
|
||||
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames, filtered)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -177,8 +181,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
fmt.Fprintln(out, "已取消。")
|
||||
return nil
|
||||
}
|
||||
} else if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
|
||||
}
|
||||
|
||||
var installed, skipped int
|
||||
@@ -186,7 +188,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
case skillSetupModeMono:
|
||||
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
|
||||
case skillSetupModeMulti:
|
||||
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut)
|
||||
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut, filtered)
|
||||
default:
|
||||
return fmt.Errorf("内部错误:未知 mode %q", mode)
|
||||
}
|
||||
@@ -251,10 +253,10 @@ func normalizeMultiSkillName(name string) string {
|
||||
// - both lists empty → return `all` (install everything)
|
||||
// - exclude that drops every name → error (avoid silent no-op install)
|
||||
//
|
||||
// The caller is responsible for additive installation: install only the
|
||||
// returned names, leaving any other already-installed dingtalk-* siblings
|
||||
// untouched (handled by installMultiSkillToHomes which does not enumerate
|
||||
// the destination).
|
||||
// The caller threads whether a filter was used into installMultiSkillToHomes:
|
||||
// filtered installs stay additive (already-installed dingtalk-* siblings are
|
||||
// left untouched); a full unfiltered install also removes stale dingtalk-* /
|
||||
// dws-shared directories that are no longer part of the bundle.
|
||||
func filterMultiSkillNames(all, include, exclude []string) ([]string, error) {
|
||||
if len(include) > 0 && len(exclude) > 0 {
|
||||
return nil, fmt.Errorf("--skill 与 --exclude 不能同时使用")
|
||||
@@ -356,8 +358,8 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
}
|
||||
|
||||
if autoYes || !skillSetupInteractive() {
|
||||
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 mono")
|
||||
return skillSetupModeMono, nil
|
||||
fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 multi")
|
||||
return skillSetupModeMulti, nil
|
||||
}
|
||||
|
||||
var choice string
|
||||
@@ -365,10 +367,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("选择 dws skill 安装模式").
|
||||
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
|
||||
Description("multi = 按产品拆分(默认)\nmono = 单 skill 入口(legacy)").
|
||||
Options(
|
||||
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
|
||||
huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
|
||||
huh.NewOption("multi — 多 skill(默认)", skillSetupModeMulti),
|
||||
huh.NewOption("mono — 单 skill(legacy)", skillSetupModeMono),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
@@ -380,8 +382,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
}
|
||||
|
||||
// resolveSkillSetupSource finds the local skill source directory for the
|
||||
// given mode. PR 1 supports only mono; multi is reserved for a later PR
|
||||
// and currently returns an error before reaching this function.
|
||||
// given mode ("mono" or "multi"). Explicit overrides (--source flag or
|
||||
// DWS_SKILL_SOURCE) win and never fall back to another source; without an
|
||||
// override the ordered candidate list (binary-adjacent, working directory,
|
||||
// ~/.dws/skills user cache) is probed for a valid skill root of that mode.
|
||||
func resolveSkillSetupSource(explicit, mode string) (string, error) {
|
||||
subdir := mode // "mono" or "multi"
|
||||
|
||||
@@ -525,16 +529,7 @@ func detectExistingAgentHomes(home, mode string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
|
||||
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
|
||||
fmt.Fprintf(out, " · 当前选择的 %d 个独立 skill 均跑过 verifier,可用但未达 stable\n", len(multiSkillNames))
|
||||
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
|
||||
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
|
||||
fmt.Fprintln(out, " 稳定版请用 --mode mono")
|
||||
fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────")
|
||||
}
|
||||
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string, filtered bool) (bool, error) {
|
||||
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
|
||||
@@ -549,10 +544,21 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
|
||||
// 列出互斥清理:装 mode 前要把对面 mode 的残留删掉
|
||||
fmt.Fprintln(out, " 互斥清理(确认后才执行):")
|
||||
for _, d := range dests {
|
||||
for _, victim := range mutualExclusionVictims(d, mode) {
|
||||
victims, _ := mutualExclusionVictims(d, mode) // 预览只读,扫描失败不阻塞确认
|
||||
for _, victim := range victims {
|
||||
fmt.Fprintf(out, " × 将删除 %s\n", victim)
|
||||
}
|
||||
}
|
||||
// 全量 multi 安装还会清掉不在 bundle 内的过期 dingtalk-* / dws-shared
|
||||
// (removeStaleMultiSkills);这些删除同样必须先进入确认预览。带
|
||||
// -s/-x 的 filtered 安装是 additive 语义,不会动未列出的 sibling。
|
||||
if mode == skillSetupModeMulti && !filtered {
|
||||
for _, d := range dests {
|
||||
for _, victim := range staleMultiSkillVictims(d, multiSkillNames) {
|
||||
fmt.Fprintf(out, " × 将删除过期 skill %s\n", victim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !skillSetupInteractive() {
|
||||
return true, nil
|
||||
@@ -580,38 +586,49 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
|
||||
//
|
||||
// - mono dest is <agent-home>/dws → multi 残留是 <agent-home>/dingtalk-*
|
||||
// - multi dest is <agent-home> → mono 残留是 <agent-home>/dws
|
||||
func mutualExclusionVictims(dest, mode string) []string {
|
||||
//
|
||||
// A scan failure (e.g. unreadable agent home) is returned as a non-nil error
|
||||
// so callers can surface a warning instead of silently skipping cleanup.
|
||||
func mutualExclusionVictims(dest, mode string) ([]string, error) {
|
||||
switch mode {
|
||||
case skillSetupModeMono:
|
||||
// dest = <agent-home>/dws → agent-home = parent
|
||||
agentHome := filepath.Dir(dest)
|
||||
entries, err := skillSetupReadDir(agentHome)
|
||||
if err != nil {
|
||||
return nil
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("扫描 multi 残留失败 %s: %w", agentHome, err)
|
||||
}
|
||||
var victims []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && strings.HasPrefix(e.Name(), "dingtalk-") {
|
||||
if e.IsDir() && (strings.HasPrefix(e.Name(), multiSkillPrefix) || e.Name() == multiSharedSkill) {
|
||||
victims = append(victims, filepath.Join(agentHome, e.Name()))
|
||||
}
|
||||
}
|
||||
sort.Strings(victims)
|
||||
return victims
|
||||
return victims, nil
|
||||
case skillSetupModeMulti:
|
||||
// dest = <agent-home> → mono 残留是 dest/dws
|
||||
monoPath := filepath.Join(dest, "dws")
|
||||
if _, err := skillSetupStat(monoPath); err == nil {
|
||||
return []string{monoPath}
|
||||
return []string{monoPath}, nil
|
||||
}
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// cleanupMutualExclusion best-effort removes the opposite-mode leftovers.
|
||||
// Failures emit a warning to errOut but never abort the install.
|
||||
// Failures — including a failed victim scan — emit a warning to errOut but
|
||||
// never abort the install.
|
||||
func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
|
||||
for _, victim := range mutualExclusionVictims(dest, mode) {
|
||||
victims, scanErr := mutualExclusionVictims(dest, mode)
|
||||
if scanErr != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ 互斥清理扫描失败(继续安装) %s: %v\n", dest, scanErr)
|
||||
}
|
||||
for _, victim := range victims {
|
||||
if err := skillSetupRemoveAll(victim); err != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ 互斥清理失败(继续安装) %s: %v\n", victim, err)
|
||||
continue
|
||||
@@ -650,7 +667,13 @@ func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (ins
|
||||
// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
|
||||
// dest as a sibling skill directory. installed/skipped is counted per
|
||||
// (agent-home × sub-skill) pair so the user sees granular progress.
|
||||
func installMultiSkillToHomes(src string, skillNames []string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
|
||||
//
|
||||
// filtered mirrors whether runSkillSetup saw -s/--skill or -x/--exclude:
|
||||
// a filtered install stays additive and never touches siblings outside the
|
||||
// requested set; a full (unfiltered) install additionally removes stale
|
||||
// dingtalk-* / dws-shared directories that are no longer in the bundle,
|
||||
// matching install.sh / install.ps1 / install.js / upgrade paths.
|
||||
func installMultiSkillToHomes(src string, skillNames []string, dests []string, out, errOut io.Writer, filtered bool) (installed, skipped int, err error) {
|
||||
sort.Strings(dests)
|
||||
for _, dest := range dests {
|
||||
// 互斥清理:装 multi 前先把 dest/dws/ 整个删除(mono 残留)
|
||||
@@ -662,6 +685,10 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
||||
continue
|
||||
}
|
||||
|
||||
if !filtered {
|
||||
removeStaleMultiSkills(dest, skillNames, out, errOut)
|
||||
}
|
||||
|
||||
for _, name := range skillNames {
|
||||
subSrc := filepath.Join(src, name)
|
||||
subDest := filepath.Join(dest, name)
|
||||
@@ -682,6 +709,65 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
||||
return installed, skipped, nil
|
||||
}
|
||||
|
||||
// staleMultiSkillVictims lists the dingtalk-* / dws-shared directories under
|
||||
// dest that a full (unfiltered) multi install would delete because they are
|
||||
// not part of the bundle. It is the read-only preview companion of
|
||||
// removeStaleMultiSkills; scan failures degrade to a nil list so the
|
||||
// confirmation prompt is never blocked by an unreadable agent home.
|
||||
func staleMultiSkillVictims(dest string, keep []string) []string {
|
||||
entries, err := skillSetupReadDir(dest)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
keepSet := make(map[string]bool, len(keep))
|
||||
for _, n := range keep {
|
||||
keepSet[n] = true
|
||||
}
|
||||
var victims []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || keepSet[e.Name()] {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(e.Name(), multiSkillPrefix) && e.Name() != multiSharedSkill {
|
||||
continue
|
||||
}
|
||||
victims = append(victims, filepath.Join(dest, e.Name()))
|
||||
}
|
||||
sort.Strings(victims)
|
||||
return victims
|
||||
}
|
||||
|
||||
// removeStaleMultiSkills deletes dingtalk-* / dws-shared directories under
|
||||
// dest that are not part of the current bundle. Best-effort: scan/removal
|
||||
// failures warn on errOut and never abort the install.
|
||||
func removeStaleMultiSkills(dest string, keep []string, out, errOut io.Writer) {
|
||||
entries, err := skillSetupReadDir(dest)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
fmt.Fprintf(errOut, " ⚠️ 过期 skill 扫描失败(继续安装) %s: %v\n", dest, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
keepSet := make(map[string]bool, len(keep))
|
||||
for _, n := range keep {
|
||||
keepSet[n] = true
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || keepSet[e.Name()] {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(e.Name(), multiSkillPrefix) && e.Name() != multiSharedSkill {
|
||||
continue
|
||||
}
|
||||
stale := filepath.Join(dest, e.Name())
|
||||
if err := skillSetupRemoveAll(stale); err != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ 过期 skill 清理失败(继续安装) %s: %v\n", stale, err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(out, " × 已清理过期 skill %s\n", stale)
|
||||
}
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return skillSetupWalk(src, func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
|
||||
@@ -61,7 +61,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
fail := errors.New("failure")
|
||||
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
|
||||
skillSetupResolveSource = func(string, string) (string, func(), error) { return "source", func() {}, nil }
|
||||
skillSetupResolveTargets = func(string, string) ([]string, error) { return []string{"dest"}, nil }
|
||||
skillSetupResolveTargets = func(string, string) ([]string, error) { return []string{filepath.Join(t.TempDir(), "dest")}, nil }
|
||||
skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil }
|
||||
|
||||
skillSetupListMulti = func(string) ([]string, error) { return nil, fail }
|
||||
@@ -86,12 +86,12 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, fail }
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string, bool) (bool, error) { return false, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("confirmation failure should propagate")
|
||||
}
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, nil }
|
||||
skillSetupConfirm = func(io.Writer, string, string, []string, []string, bool) (bool, error) { return false, nil }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -113,7 +113,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail }
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) { return 0, 0, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("multi install failure should propagate")
|
||||
@@ -143,7 +143,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
t.Fatal("interactive mode failure should propagate")
|
||||
}
|
||||
skillSetupRunForm = func(*huh.Form) error { return nil }
|
||||
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMono {
|
||||
if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMulti {
|
||||
t.Fatalf("interactive default choice = %q, %v", got, err)
|
||||
}
|
||||
|
||||
@@ -202,11 +202,11 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
skillSetupReadDir, skillSetupStat = oldReadDir, oldStat
|
||||
var out, errOut bytes.Buffer
|
||||
skillSetupRunForm = func(*huh.Form) error { return fail }
|
||||
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}); err == nil {
|
||||
if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}, false); err == nil {
|
||||
t.Fatal("confirmation form failure should propagate")
|
||||
}
|
||||
skillSetupRunForm = func(*huh.Form) error { return nil }
|
||||
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil); err != nil || ok {
|
||||
if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil, false); err != nil || ok {
|
||||
t.Fatalf("EOF confirmation = %v, %v", ok, err)
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
@@ -231,18 +231,18 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{"dest"}, &out, &errOut)
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
|
||||
if skipped != 2 {
|
||||
t.Fatal("multi mkdir failure count mismatch")
|
||||
}
|
||||
skillSetupMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
skillSetupRemoveAll = func(string) error { return fail }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{"dest"}, &out, &errOut)
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
|
||||
if skipped != 1 {
|
||||
t.Fatal("multi remove failure count mismatch")
|
||||
}
|
||||
skillSetupRemoveAll = func(string) error { return nil }
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{"dest"}, &out, &errOut)
|
||||
_, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut, true)
|
||||
if skipped != 1 {
|
||||
t.Fatal("multi copy failure count mismatch")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -36,14 +38,14 @@ func TestResolveSkillSetupModeFlagDirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillSetupModeNonInteractiveDefaultsMono(t *testing.T) {
|
||||
func TestResolveSkillSetupModeNonInteractiveDefaultsMulti(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
got, err := resolveSkillSetupMode("", true, &buf)
|
||||
if err != nil || got != skillSetupModeMono {
|
||||
t.Fatalf("non-interactive empty mode should default to mono, got %q err=%v", got, err)
|
||||
if err != nil || got != skillSetupModeMulti {
|
||||
t.Fatalf("non-interactive empty mode should default to multi, got %q err=%v", got, err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "mono") {
|
||||
t.Fatalf("expected output to mention mono fallback, got %q", buf.String())
|
||||
if !strings.Contains(buf.String(), "multi") {
|
||||
t.Fatalf("expected output to mention multi fallback, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +212,7 @@ func TestInstallMultiSkillToHomes(t *testing.T) {
|
||||
dst2 := filepath.Join(t.TempDir(), ".cursor", "skills")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr)
|
||||
installed, skipped, err := installMultiSkillToHomes(src, got, []string{dst1, dst2}, &stdout, &stderr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("installMultiSkillToHomes err: %v", err)
|
||||
}
|
||||
@@ -254,13 +256,16 @@ func TestSkillSetupMutualExclusion(t *testing.T) {
|
||||
}
|
||||
|
||||
// Confirm mutualExclusionVictims sees the leftover
|
||||
victims := mutualExclusionVictims(agentHome, skillSetupModeMulti)
|
||||
victims, vErr := mutualExclusionVictims(agentHome, skillSetupModeMulti)
|
||||
if vErr != nil {
|
||||
t.Fatalf("mutualExclusionVictims err: %v", vErr)
|
||||
}
|
||||
if len(victims) != 1 || victims[0] != monoLeftover {
|
||||
t.Fatalf("expected victims=[%s], got %v", monoLeftover, victims)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr)
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
@@ -482,7 +487,7 @@ func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) {
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr)
|
||||
installed, skipped, err := installMultiSkillToHomes(src, filtered, []string{agentHome}, &stdout, &stderr, true)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
@@ -549,3 +554,114 @@ func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
|
||||
t.Fatalf("expected %s, got %s", multiDir, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillSetupMultiFullInstallCleansStale verifies that a full (unfiltered)
|
||||
// multi install removes stale dingtalk-* / dws-shared directories that are no
|
||||
// longer part of the bundle, matching install.sh / install.js / upgrade paths.
|
||||
// The additive counterpart (filtered install) is covered by
|
||||
// TestSkillSetupMultiAdditivePreservesSiblings.
|
||||
func TestSkillSetupMultiFullInstallCleansStale(t *testing.T) {
|
||||
names := []string{"dingtalk-aitable"}
|
||||
src := writeMultiSkillSource(t, names)
|
||||
|
||||
agentHome := filepath.Join(t.TempDir(), ".claude", "skills")
|
||||
// Stale multi skills absent from the bundle, plus a non-DWS dir that must survive.
|
||||
for _, n := range []string{"dingtalk-stale", "dws-shared", "other-skill"} {
|
||||
dir := filepath.Join(agentHome, n)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("OLD "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(src, names, []string{agentHome}, &stdout, &stderr, false)
|
||||
if err != nil {
|
||||
t.Fatalf("install err: %v (stderr=%s)", err, stderr.String())
|
||||
}
|
||||
if installed != 1 || skipped != 0 {
|
||||
t.Fatalf("expected installed=1 skipped=0, got %d/%d", installed, skipped)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(agentHome, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||
t.Errorf("missing installed skill: %v", err)
|
||||
}
|
||||
for _, stale := range []string{"dingtalk-stale", "dws-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(agentHome, stale)); !os.IsNotExist(err) {
|
||||
t.Errorf("stale %q should be removed by a full multi install (stat err=%v)", stale, err)
|
||||
}
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(agentHome, "other-skill", "SKILL.md"))
|
||||
if err != nil || !strings.HasPrefix(string(body), "OLD ") {
|
||||
t.Errorf("non-DWS dir must be preserved (body=%q, err=%v)", string(body), err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "已清理过期 skill") {
|
||||
t.Errorf("expected stale cleanup log line, got stdout=%q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillSetupMutualExclusionScanWarning verifies that a victim-scan failure
|
||||
// surfaces as an errOut warning instead of silently skipping cleanup.
|
||||
func TestSkillSetupMutualExclusionScanWarning(t *testing.T) {
|
||||
oldReadDir := skillSetupReadDir
|
||||
t.Cleanup(func() { skillSetupReadDir = oldReadDir })
|
||||
scanFail := errors.New("scan boom")
|
||||
skillSetupReadDir = func(string) ([]os.DirEntry, error) { return nil, scanFail }
|
||||
|
||||
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
|
||||
if _, err := mutualExclusionVictims(monoDest, skillSetupModeMono); err == nil {
|
||||
t.Fatal("scan failure should surface as an error")
|
||||
}
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut)
|
||||
if !strings.Contains(errOut.String(), "互斥清理扫描失败") {
|
||||
t.Fatalf("expected scan warning on errOut, got %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunSkillSetupThreadsFilteredFlag verifies runSkillSetup tells
|
||||
// installMultiSkillToHomes whether -s/--skill or -x/--exclude was used, so a
|
||||
// full install cleans stale siblings while a filtered install stays additive.
|
||||
func TestRunSkillSetupThreadsFilteredFlag(t *testing.T) {
|
||||
oldMode, oldSource, oldTargets := skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets
|
||||
oldList, oldFilter, oldMulti := skillSetupListMulti, skillSetupFilterMulti, skillSetupInstallMulti
|
||||
t.Cleanup(func() {
|
||||
skillSetupResolveMode, skillSetupResolveSource, skillSetupResolveTargets = oldMode, oldSource, oldTargets
|
||||
skillSetupListMulti, skillSetupFilterMulti, skillSetupInstallMulti = oldList, oldFilter, oldMulti
|
||||
})
|
||||
|
||||
skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil }
|
||||
skillSetupResolveSource = func(string, string) (string, func(), error) { return "source", func() {}, nil }
|
||||
skillSetupResolveTargets = func(string, string) ([]string, error) {
|
||||
return []string{filepath.Join(t.TempDir(), "dest")}, nil
|
||||
}
|
||||
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-aitable", "dws-shared"}, nil }
|
||||
skillSetupFilterMulti = filterMultiSkillNames
|
||||
var gotFiltered []bool
|
||||
skillSetupInstallMulti = func(_ string, _ []string, _ []string, _, _ io.Writer, filtered bool) (int, int, error) {
|
||||
gotFiltered = append(gotFiltered, filtered)
|
||||
return 1, 0, nil
|
||||
}
|
||||
|
||||
// Full install (no -s/-x): filtered must be false.
|
||||
cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("full install run err: %v", err)
|
||||
}
|
||||
|
||||
// Filtered install: filtered must be true.
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.Flags().Set("skill", "aitable"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("filtered install run err: %v", err)
|
||||
}
|
||||
|
||||
if len(gotFiltered) != 2 || gotFiltered[0] != false || gotFiltered[1] != true {
|
||||
t.Fatalf("filtered flag threading = %v, want [false true]", gotFiltered)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ var (
|
||||
downloadUpgradeProgress = upgrade.DownloadWithProgress
|
||||
extractUpgradeZip = upgrade.ExtractZip
|
||||
findExtractedBinary = upgrade.FindBinaryInDir
|
||||
locateUpgradeSkill = upgrade.LocateSkillMD
|
||||
locateUpgradeSkill = upgrade.LocateSkillsRoot
|
||||
replaceUpgradeSelf = upgrade.ReplaceSelf
|
||||
installUpgradeSkills = upgrade.UpgradeSkillLocations
|
||||
upgradeMkdirTemp = os.MkdirTemp
|
||||
|
||||
@@ -429,7 +429,7 @@ func TestCrossPlatformCoverageUpgradeBinaryHelpersFailureCoverage(t *testing.T)
|
||||
}
|
||||
upgradeMkdirAll = func(string, os.FileMode) error { return nil }
|
||||
upgradeCommandOutput = func(string, ...string) ([]byte, error) { return []byte("tar failed"), fail }
|
||||
if err := extractTarGz("archive", "dest"); err == nil || !strings.Contains(err.Error(), "tar failed") {
|
||||
if err := extractTarGz("archive", filepath.Join(t.TempDir(), "dest")); err == nil || !strings.Contains(err.Error(), "tar failed") {
|
||||
t.Fatalf("tar error = %v", err)
|
||||
}
|
||||
upgradeUserHomeDir = func() (string, error) { return "", fail }
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
)
|
||||
|
||||
// TestUpgradeSkillLocationsMonoSeedMigratesToMulti is the fake-HOME E2E for
|
||||
// the 2026-08-05 owner decision: upgrade is not disk-sticky. Seeding a mono
|
||||
// layout then calling UpgradeSkillLocations with a multi bundle must install
|
||||
// product skills, remove dws/, and leave non-DWS dirs alone.
|
||||
func TestUpgradeSkillLocationsMonoSeedMigratesToMulti(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
upgrade.SwapUserHomeDirForTest(t, func() (string, error) { return home, nil })
|
||||
|
||||
agentsBase := filepath.Join(home, ".agents", "skills")
|
||||
if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(agentsBase, "dws", "SKILL.md"), []byte("old mono"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(agentsBase, "other-skill"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(agentsBase, "other-skill", "SKILL.md"), []byte("not dws"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
extract := t.TempDir()
|
||||
multiRoot := filepath.Join(extract, "multi")
|
||||
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
|
||||
dir := filepath.Join(multiRoot, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err := upgrade.UpgradeSkillLocations(multiRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeSkillLocations() error = %v", err)
|
||||
}
|
||||
if failed := result.Failed(); len(failed) != 0 {
|
||||
t.Fatalf("expected 0 failures, got %v", failed)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
|
||||
t.Fatalf("mono leftover dws/ must be gone, stat err=%v", err)
|
||||
}
|
||||
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
|
||||
t.Errorf("multi skill missing: %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
|
||||
t.Errorf("non-DWS dir should be preserved: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -78,9 +79,7 @@ func installRejectedTokenHookStore(t *testing.T, data TokenData) *rejectedTokenH
|
||||
func installOAuthRefreshStub(t *testing.T, fn func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error)) {
|
||||
t.Helper()
|
||||
resetRejectedTokenRefreshCoordinator(t)
|
||||
previous := oauthRefreshToken
|
||||
oauthRefreshToken = fn
|
||||
t.Cleanup(func() { oauthRefreshToken = previous })
|
||||
testseam.Swap(t, &oauthRefreshToken, fn)
|
||||
}
|
||||
|
||||
func resetRejectedTokenRefreshCoordinator(t *testing.T) {
|
||||
@@ -121,11 +120,10 @@ func installProfilesAcquireProbe(t *testing.T) <-chan struct{} {
|
||||
t.Helper()
|
||||
previous := profilesAcquireDualLock
|
||||
attempted := make(chan struct{}, 1)
|
||||
profilesAcquireDualLock = func(ctx context.Context, configDir string) (*DualLock, error) {
|
||||
testseam.Swap(t, &profilesAcquireDualLock, func(ctx context.Context, configDir string) (*DualLock, error) {
|
||||
attempted <- struct{}{}
|
||||
return previous(ctx, configDir)
|
||||
}
|
||||
t.Cleanup(func() { profilesAcquireDualLock = previous })
|
||||
})
|
||||
return attempted
|
||||
}
|
||||
|
||||
|
||||
@@ -4,31 +4,31 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeAnnotationAPIsCoverage(t *testing.T) {
|
||||
AttachRuntimeSchema(nil, "p", "t", "s")
|
||||
AttachRuntimeSchema(&cobra.Command{}, "", "", "")
|
||||
AnnotateRuntimeToolMetadata(nil, "", "", "")
|
||||
AnnotateRuntimeFlag(nil, "x", "x", "string", false, "")
|
||||
AnnotateRuntimeFlag(nil, "x", "x", "string", false)
|
||||
AnnotateRuntimeFlagProperty(nil, "x", "x")
|
||||
AnnotateRuntimeRequiredFlags(nil, "x")
|
||||
AnnotateRuntimeFlagRequiredValue(nil, "x", false)
|
||||
AnnotateRuntimeFlagInterfaceType(nil, "x", "string")
|
||||
AnnotateRuntimeFlagRequiredWhen(nil, "x", "when")
|
||||
AnnotateRuntimeFlagFormat(nil, "x", "uri")
|
||||
AnnotateRuntimeFlagEnum(nil, "x", "a")
|
||||
AnnotateRuntimeFlagExample(nil, "x", "a")
|
||||
AnnotateRuntimeConstraints(nil, RuntimeSchemaConstraints{})
|
||||
AnnotateRuntimePositionals(nil)
|
||||
ExcludeFromRuntimeSchema(nil)
|
||||
|
||||
cmd := &cobra.Command{Use: "run", Run: func(*cobra.Command, []string) {}}
|
||||
cmd.Flags().StringP("value", "v", "", "value")
|
||||
AttachRuntimeSchema(cmd, " product ", " tool ", " source ")
|
||||
AnnotateRuntimeToolMetadata(cmd, " Title ", " Description ", " source ")
|
||||
AnnotateRuntimeFlag(cmd, "", "property", "string", true, "")
|
||||
AnnotateRuntimeFlag(cmd, "missing", "property", "string", true, "")
|
||||
AnnotateRuntimeFlag(cmd, "value", " property ", " string ", true, "")
|
||||
AnnotateRuntimeFlag(cmd, "", "property", "string", true)
|
||||
AnnotateRuntimeFlag(cmd, "missing", "property", "string", true)
|
||||
AnnotateRuntimeFlag(cmd, "value", " property ", " string ", true)
|
||||
AnnotateRuntimeFlagProperty(cmd, "missing", "property")
|
||||
AnnotateRuntimeFlagProperty(cmd, "value", "property2")
|
||||
AnnotateRuntimeRequiredFlags(cmd, "missing", "value")
|
||||
@@ -44,23 +44,22 @@ func TestCrossPlatformCoverageRuntimeAnnotationAPIsCoverage(t *testing.T) {
|
||||
AnnotateRuntimeConstraints(cmd, RuntimeSchemaConstraints{RequireOneOf: [][]string{{"value", "other"}}})
|
||||
AnnotateRuntimeConstraints(cmd, RuntimeSchemaConstraints{RequireTogether: [][]string{{"value", "other"}}})
|
||||
AnnotateRuntimePositionals(cmd,
|
||||
RuntimeSchemaPositional{Name: "", Index: 0},
|
||||
RuntimeSchemaPositional{Name: "bad", Index: -1},
|
||||
RuntimeSchemaPositional{Name: " second ", Index: 1, Description: " desc "},
|
||||
RuntimeSchemaPositional{Name: "first", Index: 0, Type: " number "},
|
||||
contract.RuntimeSchemaPositional{Name: "", Index: 0},
|
||||
contract.RuntimeSchemaPositional{Name: "bad", Index: -1},
|
||||
contract.RuntimeSchemaPositional{Name: " second ", Index: 1, Description: " desc "},
|
||||
contract.RuntimeSchemaPositional{Name: "first", Index: 0, Type: " number "},
|
||||
)
|
||||
ExcludeFromRuntimeSchema(cmd)
|
||||
setRuntimeCommandAnnotation(cmd, "empty", " ")
|
||||
setFlagAnnotation(nil, "x", "y")
|
||||
setFlagAnnotation(cmd.Flags().Lookup("value"), "empty", " ")
|
||||
setFlagAnnotationValues(nil, "x", "y")
|
||||
setFlagAnnotationValues(cmd.Flags().Lookup("value"), "empty", " ")
|
||||
if cmd.Annotations[runtimeSchemaProductAnnotation] != "product" || cmd.Annotations[runtimeSchemaExcludeAnnotation] != "true" {
|
||||
if cmd.Annotations[runtimeSchemaProductAnnotation] != "product" {
|
||||
t.Fatalf("command annotations = %#v", cmd.Annotations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeRegistriesAndSchemaHintsCoverage(t *testing.T) {
|
||||
func TestCrossPlatformCoverageRuntimeRegistriesCoverage(t *testing.T) {
|
||||
originalConstraints := runtimeSchemaConstraintsByCanonical
|
||||
originalParameters := runtimeSchemaParameterMetadataByCanonical
|
||||
t.Cleanup(func() {
|
||||
@@ -94,21 +93,6 @@ func TestCrossPlatformCoverageRuntimeRegistriesAndSchemaHintsCoverage(t *testing
|
||||
t.Fatal("cloneRuntimeSchemaStringMap(nil) != nil")
|
||||
}
|
||||
assertPanics(t, func() { RegisterRuntimeSchemaParameterMetadata("sample.run", metadata) })
|
||||
|
||||
registry := newSchemaHintRegistry()
|
||||
assertPanics(t, func() { registry.RegisterProduct("", nil) })
|
||||
assertPanics(t, func() { registry.RegisterProduct("sample", map[string]ToolSchemaHint{"": {}}) })
|
||||
registry.RegisterProduct(" sample ", map[string]ToolSchemaHint{"run": {Title: "Run"}, "other.path": {Title: "Other"}})
|
||||
assertPanics(t, func() { registry.RegisterProduct("sample", map[string]ToolSchemaHint{"run": {}}) })
|
||||
if _, ok := registry.Lookup(" "); ok {
|
||||
t.Fatal("Lookup(empty) succeeded")
|
||||
}
|
||||
if hint, ok := registry.Lookup(" sample.run "); !ok || hint.Title != "Run" {
|
||||
t.Fatalf("Lookup(sample.run) = %#v, %v", hint, ok)
|
||||
}
|
||||
if canonicalHintPath("sample", "") != "" || canonicalHintPath("sample", "other.path") != "other.path" || canonicalHintPath("sample", "run") != "sample.run" {
|
||||
t.Fatal("canonicalHintPath mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func assertPanics(t *testing.T, fn func()) {
|
||||
|
||||
+37
-209
@@ -14,52 +14,44 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var schemaCommandCatalogError = embeddedSchemaCatalogError
|
||||
// schemaCommandCatalogError / payloads use deliverySchemaCatalog
|
||||
// (RegisterSchemaSourceRoot → ResolveSchemaBuild). There is no committed
|
||||
// Schema Catalog embed fallback.
|
||||
var schemaCommandCatalogError = deliverySchemaCatalogError
|
||||
|
||||
type FlagKind string
|
||||
|
||||
const (
|
||||
flagString FlagKind = "string"
|
||||
flagInteger FlagKind = "integer"
|
||||
flagNumber FlagKind = "number"
|
||||
flagBoolean FlagKind = "boolean"
|
||||
flagStringArray FlagKind = "string_array"
|
||||
flagIntegerList FlagKind = "integer_array"
|
||||
flagNumberList FlagKind = "number_array"
|
||||
flagBooleanList FlagKind = "boolean_array"
|
||||
flagJSON FlagKind = "json"
|
||||
)
|
||||
|
||||
type FlagSpec struct {
|
||||
PropertyName string
|
||||
FlagName string
|
||||
Alias string
|
||||
Shorthand string
|
||||
Kind FlagKind
|
||||
Description string
|
||||
}
|
||||
|
||||
// NewMCPCommand returns a stub command since the canonical discovery
|
||||
// surface has been removed. The command tree is now built from plugins
|
||||
// and static endpoint registration only.
|
||||
func NewMCPCommand(_ context.Context, _ CatalogLoader, _ executor.Runner, _ *pipeline.Engine) *cobra.Command {
|
||||
// NewMCPCommand registers the mcp product declaration and returns its root
|
||||
// command. The app layer attaches reviewed static MCP helpers as
|
||||
// subcommands; live discovery surfaces are retired.
|
||||
func NewMCPCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/mcp.json
|
||||
// products.mcp). Schema assembly stamps provenance contract_final.
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "mcp",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "解析和管理当前身份可用的 MCP 服务连接信息",
|
||||
UseWhen: []string{
|
||||
"需要把钉钉 MCP 市场中的服务连接到支持 Streamable HTTP 的 Agent 或客户端",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"查询普通钉钉业务数据时使用对应产品命令,不要使用 mcp",
|
||||
},
|
||||
},
|
||||
})
|
||||
// The legacy dynamic MCP surface has been removed; the app layer adds
|
||||
// reviewed static MCP helpers as subcommands of this command.
|
||||
cmd := &cobra.Command{
|
||||
Use: "mcp",
|
||||
Short: "Canonical MCP-derived CLI surface (static mode)",
|
||||
Long: "The canonical MCP command surface is disabled. Commands are now registered via plugins and static endpoints.",
|
||||
Hidden: true,
|
||||
Short: "管理 MCP 服务连接信息",
|
||||
Long: "管理经过审核并纳入 Schema 的 MCP 服务连接辅助能力。",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -69,16 +61,17 @@ func NewMCPCommand(_ context.Context, _ CatalogLoader, _ executor.Runner, _ *pip
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewSchemaCommand serves the versioned embedded typed contract. A malformed
|
||||
// release snapshot fails closed; falling back to the live Cobra tree would
|
||||
// hide a broken delivery artifact and reintroduce a second Schema data path.
|
||||
func NewSchemaCommand(_ CatalogLoader) *cobra.Command {
|
||||
// NewSchemaCommand serves the typed Schema contract. Production assembles from
|
||||
// declarations via ResolveSchemaBuild (factory registered by internal/app).
|
||||
// A malformed assembly fails closed; the command takes no discovery loader
|
||||
// because queries never run service discovery.
|
||||
func NewSchemaCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "schema [path]",
|
||||
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
|
||||
Long: `查看当前可运行命令的 Schema 元数据。
|
||||
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。--all 输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)。--compact 去除 provenance / debug 字段,仅保留 Agent 选参所需信息(适合 Agent 上下文)。helper、MCP 与本地 Cobra 命令均须先进入 reviewed Registry,并从同一内嵌 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。--all 输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)。--compact 去除 provenance / debug 字段,仅保留 Agent 选参所需信息(适合 Agent 上下文)。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -99,16 +92,16 @@ func NewSchemaCommand(_ CatalogLoader) *cobra.Command {
|
||||
args = []string{cliPath}
|
||||
}
|
||||
if err := schemaCommandCatalogError(); err != nil {
|
||||
return fmt.Errorf("load embedded typed Schema registry: %w", err)
|
||||
return fmt.Errorf("load typed Schema registry: %w", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
var err error
|
||||
if all {
|
||||
payload, err = embeddedSchemaAllPayload()
|
||||
payload, err = deliverySchemaAllPayload()
|
||||
} else if len(args) == 0 {
|
||||
payload, err = embeddedSchemaOverviewPayload()
|
||||
payload, err = deliverySchemaOverviewPayload()
|
||||
} else {
|
||||
payload, err = embeddedSchemaPayload(args)
|
||||
payload, err = queryDeliverySchemaPayload(args)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -125,171 +118,6 @@ func NewSchemaCommand(_ CatalogLoader) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func BuildFlagSpecs(schema map[string]any, hints map[string]CLIFlagHint) []FlagSpec {
|
||||
properties, ok := nestedMap(schema, "properties")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(properties))
|
||||
for key := range properties {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
specs := make([]FlagSpec, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
propertySchema, ok := properties[key].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
kind, ok := flagKindForSchema(propertySchema)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
specs = append(specs, FlagSpec{
|
||||
PropertyName: key,
|
||||
FlagName: strings.ReplaceAll(key, "_", "-"),
|
||||
Alias: strings.TrimSpace(hints[key].Alias),
|
||||
Shorthand: strings.TrimSpace(hints[key].Shorthand),
|
||||
Kind: kind,
|
||||
Description: schemaDescription(propertySchema),
|
||||
})
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
// canRegisterToolFlag reports whether a long flag named name can be
|
||||
// registered on cmd without panicking pflag ("flag redefined").
|
||||
func canRegisterToolFlag(cmd *cobra.Command, name string) bool {
|
||||
if name == "" || name == "json" || name == "params" {
|
||||
return false
|
||||
}
|
||||
return cmd.Flags().Lookup(name) == nil
|
||||
}
|
||||
|
||||
// safeToolShorthand returns short when it is a single-character shorthand not
|
||||
// yet bound on cmd; otherwise "" (drop the shorthand, keep the long flag).
|
||||
func safeToolShorthand(cmd *cobra.Command, short string) string {
|
||||
short = strings.TrimSpace(short)
|
||||
if len(short) != 1 {
|
||||
return ""
|
||||
}
|
||||
if cmd.Flags().ShorthandLookup(short) != nil {
|
||||
return ""
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) {
|
||||
for _, spec := range specs {
|
||||
usage := spec.Description
|
||||
if usage == "" {
|
||||
usage = fmt.Sprintf("Override %s", spec.PropertyName)
|
||||
}
|
||||
primary := strings.TrimSpace(spec.FlagName)
|
||||
if !canRegisterToolFlag(cmd, primary) {
|
||||
continue
|
||||
}
|
||||
shorthand := safeToolShorthand(cmd, spec.Shorthand)
|
||||
alias := strings.TrimSpace(spec.Alias)
|
||||
if alias == primary || !canRegisterToolFlag(cmd, alias) {
|
||||
alias = ""
|
||||
}
|
||||
|
||||
switch spec.Kind {
|
||||
case flagString, flagJSON:
|
||||
cmd.Flags().StringP(primary, shorthand, "", usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().String(alias, "", usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
case flagInteger:
|
||||
cmd.Flags().IntP(primary, shorthand, 0, usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().Int(alias, 0, usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
case flagNumber:
|
||||
cmd.Flags().Float64P(primary, shorthand, 0, usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().Float64(alias, 0, usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
case flagBoolean:
|
||||
cmd.Flags().BoolP(primary, shorthand, false, usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().Bool(alias, false, usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
case flagStringArray, flagIntegerList, flagNumberList, flagBooleanList:
|
||||
cmd.Flags().StringSliceP(primary, shorthand, nil, usage)
|
||||
if alias != "" {
|
||||
cmd.Flags().StringSlice(alias, nil, usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nestedMap(root map[string]any, key string) (map[string]any, bool) {
|
||||
if root == nil {
|
||||
return nil, false
|
||||
}
|
||||
value, ok := root[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out, ok := value.(map[string]any)
|
||||
return out, ok
|
||||
}
|
||||
|
||||
func flagKindForSchema(schema map[string]any) (FlagKind, bool) {
|
||||
if _, ok := schema["enum"].([]any); ok {
|
||||
return flagString, true
|
||||
}
|
||||
switch schema["type"] {
|
||||
case "string":
|
||||
return flagString, true
|
||||
case "integer":
|
||||
return flagInteger, true
|
||||
case "number":
|
||||
return flagNumber, true
|
||||
case "boolean":
|
||||
return flagBoolean, true
|
||||
case "object":
|
||||
return flagJSON, true
|
||||
case "array":
|
||||
items, ok := schema["items"].(map[string]any)
|
||||
if !ok {
|
||||
return flagJSON, true
|
||||
}
|
||||
if _, ok := items["enum"].([]any); ok {
|
||||
return flagStringArray, true
|
||||
}
|
||||
switch items["type"] {
|
||||
case "string":
|
||||
return flagStringArray, true
|
||||
case "integer":
|
||||
return flagIntegerList, true
|
||||
case "number":
|
||||
return flagNumberList, true
|
||||
case "boolean":
|
||||
return flagBooleanList, true
|
||||
case "object":
|
||||
return flagJSON, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func schemaDescription(schema map[string]any) string {
|
||||
value, _ := schema["description"].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
// splitSchemaPathTokens splits a CLI path on dots, slashes, and
|
||||
// whitespace, returning only non-empty tokens.
|
||||
func splitSchemaPathTokens(raw string) []string {
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newToolCommandFixture mirrors the flag environment of newToolCommand: the
|
||||
// reserved payload flags are registered before the spec loop runs.
|
||||
func newToolCommandFixture() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "probe"}
|
||||
cmd.Flags().String("json", "", "Base JSON object payload for this tool invocation")
|
||||
cmd.Flags().String("params", "", "Additional JSON object payload merged after --json")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// TestApplyFlagSpecsSkipsReservedNames locks in the fix for the 1.0.32-class
|
||||
// lock-out: a tool schema property named after a reserved payload flag
|
||||
// ("params", as cached during the chat_permission_grant incident, or "json")
|
||||
// must be skipped instead of panicking pflag ("flag redefined") — that panic
|
||||
// fires while the canonical tree is assembled, before Cobra dispatches
|
||||
// anything, and used to kill every invocation including `dws cache refresh`
|
||||
// and `dws upgrade`.
|
||||
func TestApplyFlagSpecsSkipsReservedNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newToolCommandFixture()
|
||||
applyFlagSpecs(cmd, []FlagSpec{
|
||||
{PropertyName: "params", FlagName: "params", Kind: flagString, Description: "命令授权参数"},
|
||||
{PropertyName: "json", FlagName: "json", Kind: flagString},
|
||||
{PropertyName: "scope", FlagName: "scope", Kind: flagString},
|
||||
})
|
||||
|
||||
if cmd.Flags().Lookup("scope") == nil {
|
||||
t.Errorf("non-colliding flag --scope was not registered")
|
||||
}
|
||||
// The reserved flags must keep their payload usage strings, proving the
|
||||
// schema-derived specs did not touch them.
|
||||
if got := cmd.Flags().Lookup("params").Usage; got != "Additional JSON object payload merged after --json" {
|
||||
t.Errorf("--params usage = %q, want the reserved payload usage", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyFlagSpecsSkipsDuplicates covers duplicate property names within a
|
||||
// single tool schema (or a spec colliding with an already-applied one).
|
||||
func TestApplyFlagSpecsSkipsDuplicates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newToolCommandFixture()
|
||||
applyFlagSpecs(cmd, []FlagSpec{
|
||||
{PropertyName: "scope", FlagName: "scope", Kind: flagString, Description: "first"},
|
||||
{PropertyName: "scope", FlagName: "scope", Kind: flagBoolean, Description: "second"},
|
||||
})
|
||||
|
||||
flag := cmd.Flags().Lookup("scope")
|
||||
if flag == nil {
|
||||
t.Fatalf("--scope was not registered at all")
|
||||
}
|
||||
if flag.Usage != "first" {
|
||||
t.Errorf("--scope usage = %q, want the first spec to win", flag.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyFlagSpecsSkipsCollidingAlias verifies an alias colliding with a
|
||||
// reserved or existing flag is dropped while the primary still registers.
|
||||
func TestApplyFlagSpecsSkipsCollidingAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newToolCommandFixture()
|
||||
applyFlagSpecs(cmd, []FlagSpec{
|
||||
{PropertyName: "scope", FlagName: "scope", Alias: "params", Kind: flagString},
|
||||
})
|
||||
|
||||
if cmd.Flags().Lookup("scope") == nil {
|
||||
t.Errorf("primary flag --scope was not registered when its alias collided")
|
||||
}
|
||||
if got := cmd.Flags().Lookup("params").Usage; got != "Additional JSON object payload merged after --json" {
|
||||
t.Errorf("--params usage = %q, alias overwrote the reserved payload flag", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyFlagSpecsSanitizesShorthand verifies multi-character and duplicate
|
||||
// shorthands (both pflag panics) degrade to long-flag-only registration.
|
||||
func TestApplyFlagSpecsSanitizesShorthand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newToolCommandFixture()
|
||||
applyFlagSpecs(cmd, []FlagSpec{
|
||||
{PropertyName: "alpha", FlagName: "alpha", Shorthand: "ab", Kind: flagString},
|
||||
{PropertyName: "beta", FlagName: "beta", Shorthand: "s", Kind: flagString},
|
||||
{PropertyName: "gamma", FlagName: "gamma", Shorthand: "s", Kind: flagString},
|
||||
})
|
||||
|
||||
for _, name := range []string{"alpha", "beta", "gamma"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Errorf("--%s was not registered", name)
|
||||
}
|
||||
}
|
||||
if flag := cmd.Flags().ShorthandLookup("s"); flag == nil || flag.Name != "beta" {
|
||||
t.Errorf("shorthand -s should stay bound to the first claimant --beta, got %v", flag)
|
||||
}
|
||||
}
|
||||
+102
-23
@@ -14,18 +14,20 @@
|
||||
// command_meta.go provides the unified metadata consumption API. All runtime
|
||||
// consumers (help, schema, agent selection, skill generation) call ResolveMeta
|
||||
// to get a CommandMeta struct — one function, one struct, no need to know which
|
||||
// of the 6 generation layers a field comes from.
|
||||
// declaration layer a field comes from.
|
||||
//
|
||||
// This is the "simple consumption" half of the generation/consumption split:
|
||||
// - Generation (gen.go + internal/generator/): 6 inputs → catalog snapshot.
|
||||
// - Consumption (this file): catalog snapshot → ResolveMeta → CommandMeta.
|
||||
// ResolveMeta projects CommandMeta from the lazily assembled SchemaRegistry
|
||||
// (RegisterSchemaSourceRoot → ResolveSchemaBuild). Without a registered
|
||||
// factory it fails closed — there is no schema_meta_index.gob fallback.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// CommandMeta is the complete runtime metadata view for a single command.
|
||||
@@ -58,21 +60,92 @@ var (
|
||||
metaByCLIPath map[string]CommandMeta
|
||||
)
|
||||
|
||||
// initMetaByCLIPath builds the cli_path → CommandMeta lookup from the embedded
|
||||
// catalog. Runs once (sync.Once); the catalog is already decoded at package init.
|
||||
func initMetaByCLIPath() {
|
||||
metaByCLIPath = buildMetaByCLIPath(embeddedSchemaCatalog())
|
||||
// Counter names retain "MetaIndex" for RuntimeSchemaMetadataLoadCounts
|
||||
// compatibility; they now count ResolveMeta lookup init from assembled Catalog.
|
||||
var (
|
||||
runtimeDeliverySchemaMetaIndexErr error
|
||||
runtimeDeliverySchemaMetaIndexLazyCount atomic.Uint64
|
||||
)
|
||||
|
||||
// installDeliveryCommandMeta materializes the ResolveMeta lookup from an
|
||||
// assembled Catalog. Called from deliverySchemaCatalog's sync.Once so Meta
|
||||
// shares assembly and subsequent ResolveMeta is a plain map read.
|
||||
func installDeliveryCommandMeta(loaded loadedSchemaCatalog, err error) {
|
||||
runtimeDeliverySchemaMetaIndexLazyCount.Add(1)
|
||||
if err != nil {
|
||||
runtimeDeliverySchemaMetaIndexErr = err
|
||||
metaByCLIPath = nil
|
||||
metaByCLIPathOnce.Do(func() {})
|
||||
return
|
||||
}
|
||||
metaByCLIPath = buildMetaByCLIPath(loaded)
|
||||
runtimeDeliverySchemaMetaIndexErr = nil
|
||||
metaByCLIPathOnce.Do(func() {})
|
||||
}
|
||||
|
||||
// buildMetaByCLIPath constructs the lookup from a loaded catalog snapshot.
|
||||
// Split from initMetaByCLIPath so malformed-snapshot guards stay testable.
|
||||
// panicIfMetaIndexUnusable fails closed when CommandMeta lookup could not be
|
||||
// built. Callers must not treat this as "command missing".
|
||||
func panicIfMetaIndexUnusable(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
panic(fmt.Sprintf("schema CommandMeta index is unusable: %v", err))
|
||||
}
|
||||
|
||||
// buildMetaByCLIPath constructs the lookup from a loaded catalog.
|
||||
// Prefer the typed Registry (production cold-start path). Fall back to
|
||||
// Snapshot.Tools maps for unit tests that synthesize untyped fixtures.
|
||||
func buildMetaByCLIPath(loaded loadedSchemaCatalog) map[string]CommandMeta {
|
||||
if len(loaded.Registry.Products) > 0 {
|
||||
return buildMetaByCLIPathFromRegistry(loaded.Registry)
|
||||
}
|
||||
return buildMetaByCLIPathFromSnapshotTools(loaded.Snapshot.Tools)
|
||||
}
|
||||
|
||||
func buildMetaByCLIPathFromRegistry(registry SchemaRegistry) map[string]CommandMeta {
|
||||
lookup := make(map[string]CommandMeta)
|
||||
if loaded.Snapshot.Tools == nil {
|
||||
metas := make([]CommandMeta, 0, 64)
|
||||
for _, product := range registry.Products {
|
||||
for _, tool := range product.Tools {
|
||||
cliPath := strings.TrimSpace(tool.Identity.CLIPath)
|
||||
if cliPath == "" {
|
||||
continue
|
||||
}
|
||||
meta := CommandMeta{
|
||||
Identity: CommandIdentity{
|
||||
CLIPath: cliPath,
|
||||
Canonical: tool.Identity.CanonicalPath,
|
||||
Aliases: append([]string(nil), tool.Identity.Aliases...),
|
||||
ProductID: tool.Identity.ProductID,
|
||||
Title: tool.Title,
|
||||
},
|
||||
Safety: CommandSafety{
|
||||
Effect: tool.Safety.Effect,
|
||||
Risk: tool.Safety.Risk,
|
||||
Confirmation: tool.Safety.Confirmation,
|
||||
Idempotency: tool.Safety.Idempotency,
|
||||
},
|
||||
Selection: CommandSelection{
|
||||
AgentSummary: tool.Selection.AgentSummary,
|
||||
UseWhen: append([]string(nil), tool.Selection.UseWhen...),
|
||||
AvoidWhen: append([]string(nil), tool.Selection.AvoidWhen...),
|
||||
Examples: append([]string(nil), tool.Selection.Examples...),
|
||||
},
|
||||
}
|
||||
lookup[cliPath] = meta
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
}
|
||||
return registerCommandMetaAliases(lookup, metas)
|
||||
}
|
||||
|
||||
func buildMetaByCLIPathFromSnapshotTools(tools map[string]map[string]any) map[string]CommandMeta {
|
||||
lookup := make(map[string]CommandMeta)
|
||||
if tools == nil {
|
||||
return lookup
|
||||
}
|
||||
metas := make([]CommandMeta, 0, len(loaded.Snapshot.Tools))
|
||||
for _, tool := range loaded.Snapshot.Tools {
|
||||
metas := make([]CommandMeta, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
cliPath := schemaString(tool["cli_path"])
|
||||
if cliPath == "" {
|
||||
continue
|
||||
@@ -101,12 +174,13 @@ func buildMetaByCLIPath(loaded loadedSchemaCatalog) map[string]CommandMeta {
|
||||
lookup[cliPath] = meta
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
// Register compat alias paths (e.g. "report list") against the same
|
||||
// metadata in a second pass, sorted by primary cli_path: primary paths
|
||||
// always win (registered above, aliases only fill vacancies), and an
|
||||
// alias-vs-alias collision resolves deterministically to the owner with
|
||||
// the lexicographically smallest primary path — Snapshot.Tools is a map,
|
||||
// so relying on iteration order would make the winner vary per process.
|
||||
return registerCommandMetaAliases(lookup, metas)
|
||||
}
|
||||
|
||||
// registerCommandMetaAliases fills compat alias paths. Primary paths always
|
||||
// win; alias-vs-alias collisions resolve to the owner with the
|
||||
// lexicographically smallest primary cli_path (map iteration is unstable).
|
||||
func registerCommandMetaAliases(lookup map[string]CommandMeta, metas []CommandMeta) map[string]CommandMeta {
|
||||
sort.Slice(metas, func(i, j int) bool {
|
||||
return metas[i].Identity.CLIPath < metas[j].Identity.CLIPath
|
||||
})
|
||||
@@ -126,10 +200,15 @@ func buildMetaByCLIPath(loaded loadedSchemaCatalog) map[string]CommandMeta {
|
||||
|
||||
// ResolveMeta returns the complete metadata for a command identified by its CLI
|
||||
// path (e.g. "dev app delete") or one of its compat aliases (e.g. "report list"
|
||||
// for "report inbox list"). Returns ok=false for commands not in the embedded
|
||||
// catalog (utility commands, hidden commands, shortcuts).
|
||||
// for "report inbox list"). Returns ok=false for commands not in the Schema
|
||||
// surface (utility commands, hidden commands, shortcuts).
|
||||
//
|
||||
// Ensures deliverySchemaCatalog Once (assembles + caches Meta), then O(1) map
|
||||
// lookup. Without a registered source root, fails closed (panic).
|
||||
func ResolveMeta(cliPath string) (CommandMeta, bool) {
|
||||
metaByCLIPathOnce.Do(initMetaByCLIPath)
|
||||
m, ok := metaByCLIPath[strings.TrimSpace(cliPath)]
|
||||
cliPath = strings.TrimSpace(cliPath)
|
||||
_ = deliverySchemaCatalog()
|
||||
panicIfMetaIndexUnusable(runtimeDeliverySchemaMetaIndexErr)
|
||||
m, ok := metaByCLIPath[cliPath]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
@@ -21,10 +21,11 @@ import (
|
||||
)
|
||||
|
||||
// CommandSafety holds the safety metadata for a CLI command, resolved at
|
||||
// runtime from the embedded schema catalog. This is a read-only view over the
|
||||
// catalog — NOT a second safety source. The catalog remains the single
|
||||
// authoritative reviewed source; this struct merely provides typed access for
|
||||
// consumers (help rendering, skill generation).
|
||||
// runtime via ResolveMeta. This is a read-only view — NOT a second safety
|
||||
// source. Production Meta is projected from the runtime-assembled
|
||||
// SchemaRegistry into a cached map[cli_path]CommandMeta during
|
||||
// deliverySchemaCatalog's sync.Once; steady-state ResolveMeta / leaf --help
|
||||
// are O(1) map lookups (同源 with assembly, not a separate gob authority).
|
||||
type CommandSafety struct {
|
||||
Effect string // read / write / destructive
|
||||
Risk string // low / medium / high
|
||||
@@ -41,12 +42,18 @@ func (s CommandSafety) ShouldRender() bool {
|
||||
}
|
||||
|
||||
// SafetyForCLIPath returns the safety metadata for a command identified by its
|
||||
// CLI path (e.g. "dev app delete"). Returns ok=false when the command is not
|
||||
// in the embedded catalog (utility commands, hidden commands, shortcuts).
|
||||
// CLI path (e.g. "dev app delete"). Returns ok=false when the path is absent
|
||||
// from the Schema surface (utility commands, hidden commands, shortcuts), or
|
||||
// when no Schema source root is registered (synthetic help trees in unit
|
||||
// tests). With a registered factory, assembly failure panics via ResolveMeta
|
||||
// (fail-closed).
|
||||
//
|
||||
// Deprecated: use ResolveMeta(cliPath).Safety for the complete metadata view.
|
||||
// Kept for backward compatibility with existing callers.
|
||||
func SafetyForCLIPath(cliPath string) (CommandSafety, bool) {
|
||||
if !SchemaSourceRootRegistered() {
|
||||
return CommandSafety{}, false
|
||||
}
|
||||
meta, ok := ResolveMeta(cliPath)
|
||||
if !ok {
|
||||
return CommandSafety{}, false
|
||||
@@ -59,7 +66,12 @@ func SafetyForCLIPath(cliPath string) (CommandSafety, bool) {
|
||||
// the shared entry point for ALL help rendering paths (root HelpFunc, product
|
||||
// group custom HelpFuncs like calendar's). It avoids the timing issue where a
|
||||
// group captures origHelp before configureRootHelp sets the root's custom func.
|
||||
// Without a registered Schema source root it is a no-op (unit-test synthetic
|
||||
// roots); production NewRootCommand always registers the factory.
|
||||
func RenderSafetyAnnotation(cmd *cobra.Command) {
|
||||
if !SchemaSourceRootRegistered() {
|
||||
return
|
||||
}
|
||||
cliPath := strings.TrimSpace(strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" "))
|
||||
safety, ok := SafetyForCLIPath(cliPath)
|
||||
if !ok || !safety.ShouldRender() {
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -37,6 +39,19 @@ func TestSafetyForCLIPathKnownCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSafetyForCLIPathRegisteredFactory(t *testing.T) {
|
||||
if !SchemaSourceRootRegistered() {
|
||||
t.Fatal("package-cli TestMain must register a Schema source root")
|
||||
}
|
||||
s, ok := SafetyForCLIPath("dev app delete")
|
||||
if !ok || s.Effect != "destructive" {
|
||||
t.Fatalf("SafetyForCLIPath = %#v ok=%v", s, ok)
|
||||
}
|
||||
if _, ok := SafetyForCLIPath("nonexistent fake command"); ok {
|
||||
t.Fatal("unknown path must return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafetyForCLIPathUnknownSkips(t *testing.T) {
|
||||
// 不存在的命令 → ok=false。
|
||||
_, ok := SafetyForCLIPath("nonexistent fake command")
|
||||
@@ -69,6 +84,26 @@ func TestSafetyForCLIPathTrimsWhitespace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMetaFailsClosedOnUnusableMetaIndex(t *testing.T) {
|
||||
// Guard is unit-tested without poisoning the process-wide sync.Once used
|
||||
// by the real embedded index. Decode failure must panic, not look like a
|
||||
// missing CLI path (which would silently drop help Safety).
|
||||
if _, err := decodeSchemaMetaIndexLookup([]byte(`{not-json`)); err == nil {
|
||||
t.Fatal("decodeSchemaMetaIndexLookup(corrupt) error = nil, want fail-closed decode error")
|
||||
}
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatal("panicIfMetaIndexUnusable(err) did not panic")
|
||||
}
|
||||
msg, _ := r.(string)
|
||||
if msg == "" || !strings.Contains(msg, "schema CommandMeta index is unusable") {
|
||||
t.Fatalf("panic = %#v, want unusable CommandMeta index message", r)
|
||||
}
|
||||
}()
|
||||
panicIfMetaIndexUnusable(fmt.Errorf("decode schema meta index: unexpected EOF"))
|
||||
}
|
||||
|
||||
func TestResolveMetaComplete(t *testing.T) {
|
||||
// dev app delete: destructive + high + user_required + 有 selection。
|
||||
m, ok := ResolveMeta("dev app delete")
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalMCPMetadataLookup(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "reply"}
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
||||
cmd.Flags().String("text", "", "text")
|
||||
runtimeannotate.AnnotateRuntimeFlag(cmd, "text", "text", "string", false)
|
||||
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "reply_personal_message", CanonicalPath: "chat.reply_personal_message",
|
||||
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeMCP,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
|
||||
},
|
||||
})
|
||||
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "chat", ToolName: "reply_personal_message", Command: cmd,
|
||||
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
|
||||
}
|
||||
metadata := runtimeSchemaMetadataSources{
|
||||
MCP: embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
|
||||
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{
|
||||
"text": {Type: "string"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
if _, err := runtimeToolSpecFromContractFinal(entry, mustFinal(t, cmd), metadata); err != nil {
|
||||
t.Fatalf("runtimeToolSpecFromContractFinal with MCP metadata = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
||||
cmd.Flags().String("mode", "", "usage")
|
||||
runtimeannotate.AnnotateRuntimeFlag(cmd, "mode", "mode", "string", false)
|
||||
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
|
||||
Title: "Final Title",
|
||||
Safety: &contract.SafetySpec{
|
||||
Effect: "write", Confirmation: "user_required", Idempotency: "none",
|
||||
},
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewInvocation},
|
||||
Selection: &contract.SelectionSpec{
|
||||
AgentSummary: "from contract",
|
||||
UseWhen: []string{"create things"},
|
||||
},
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "dev", Name: "create_thing", CanonicalPath: "dev.create_thing",
|
||||
CLIPath: "dev create", PrimaryCLIPath: "dev create",
|
||||
},
|
||||
})
|
||||
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
ToolName: "create_thing",
|
||||
CLIName: "create",
|
||||
CLIPath: "dev create",
|
||||
PrimaryCLIPath: "dev create",
|
||||
ProductName: "Dev",
|
||||
Command: cmd,
|
||||
Source: "test",
|
||||
}
|
||||
spec, err := runtimeToolSpecFromContractFinal(entry, mustFinal(t, cmd), runtimeSchemaMetadataSources{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.Title != "Final Title" {
|
||||
t.Fatalf("title = %q", spec.Title)
|
||||
}
|
||||
if spec.Safety.Confirmation != "user_required" || spec.Safety.Idempotency != "none" {
|
||||
t.Fatalf("safety = %#v", spec.Safety)
|
||||
}
|
||||
if spec.DryRun == nil || spec.DryRun.PreviewKind != contract.DryRunPreviewInvocation {
|
||||
t.Fatalf("dry_run = %#v", spec.DryRun)
|
||||
}
|
||||
if spec.Selection.AgentSummary != "from contract" {
|
||||
t.Fatalf("selection = %#v", spec.Selection)
|
||||
}
|
||||
if spec.MetadataSource != "corecmd.contract" {
|
||||
t.Fatalf("metadata_source = %q", spec.MetadataSource)
|
||||
}
|
||||
if len(spec.Parameters) != 1 || spec.Parameters[0].Name != "mode" {
|
||||
t.Fatalf("parameters = %#v", spec.Parameters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalIdentityMismatchFails(t *testing.T) {
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
ToolName: "create_thing",
|
||||
CLIName: "create",
|
||||
CLIPath: "dev create",
|
||||
PrimaryCLIPath: "dev create",
|
||||
Command: &cobra.Command{Use: "create"},
|
||||
Source: "test",
|
||||
}
|
||||
for name, id := range map[string]contract.ToolIdentitySpec{
|
||||
"wrong name": {Name: "delete_thing"},
|
||||
"wrong canonical path": {CanonicalPath: "dev.delete_thing"},
|
||||
"wrong product": {ProductID: "other"},
|
||||
"wrong cli path": {CLIPath: "dev delete"},
|
||||
"wrong aliases": {Aliases: []string{"dev rm"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := runtimeToolSpecFromContractFinal(entry, contract.ContractFinalPayload{Identity: &id}, runtimeSchemaMetadataSources{})
|
||||
if err == nil {
|
||||
t.Fatal("declared identity conflicting with bound entry must fail assembly")
|
||||
}
|
||||
})
|
||||
}
|
||||
consistent := contract.ContractFinalPayload{Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "dev", Name: "create_thing", CLIName: "create",
|
||||
CanonicalPath: "dev.create_thing", CLIPath: "dev create",
|
||||
PrimaryCLIPath: "dev create", Source: "test",
|
||||
}}
|
||||
if _, err := runtimeToolSpecFromContractFinal(entry, consistent, runtimeSchemaMetadataSources{}); err != nil {
|
||||
t.Fatalf("declared identity matching bound entry must pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalRejectsReviewedSelection(t *testing.T) {
|
||||
reviewed := true
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
ToolName: "create_thing",
|
||||
CLIName: "create",
|
||||
CLIPath: "dev create",
|
||||
PrimaryCLIPath: "dev create",
|
||||
Command: &cobra.Command{Use: "create"},
|
||||
Source: "test",
|
||||
}
|
||||
_, err := runtimeToolSpecFromContractFinal(entry, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "dev", Name: "create_thing", CanonicalPath: "dev.create_thing",
|
||||
CLIPath: "dev create", PrimaryCLIPath: "dev create",
|
||||
CLIName: "create", Source: "test",
|
||||
},
|
||||
Selection: &contract.SelectionSpec{AgentSummary: "sum", Reviewed: &reviewed},
|
||||
}, runtimeSchemaMetadataSources{})
|
||||
if err == nil || !strings.Contains(err.Error(), "must not carry reviewed field") {
|
||||
t.Fatalf("declaration payload carrying Reviewed must fail assembly, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalOptionalIdentityMismatch(t *testing.T) {
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
ToolName: "create_thing",
|
||||
CLIName: "create",
|
||||
Group: "dev thing",
|
||||
CLIPath: "dev create",
|
||||
PrimaryCLIPath: "dev create",
|
||||
Command: &cobra.Command{Use: "create"},
|
||||
Source: "test",
|
||||
}
|
||||
for name, id := range map[string]contract.ToolIdentitySpec{
|
||||
"cli_name": {
|
||||
ProductID: "dev", Name: "create_thing", CanonicalPath: "dev.create_thing",
|
||||
CLIPath: "dev create", PrimaryCLIPath: "dev create", CLIName: "other", Source: "test",
|
||||
},
|
||||
"group": {
|
||||
ProductID: "dev", Name: "create_thing", CanonicalPath: "dev.create_thing",
|
||||
CLIPath: "dev create", PrimaryCLIPath: "dev create", CLIName: "create",
|
||||
Group: "other group", Source: "test",
|
||||
},
|
||||
"source": {
|
||||
ProductID: "dev", Name: "create_thing", CanonicalPath: "dev.create_thing",
|
||||
CLIPath: "dev create", PrimaryCLIPath: "dev create", CLIName: "create",
|
||||
Source: "other",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := runtimeToolSpecFromContractFinal(entry, contract.ContractFinalPayload{Identity: &id}, runtimeSchemaMetadataSources{})
|
||||
if err == nil || !strings.Contains(err.Error(), name+":") {
|
||||
t.Fatalf("optional identity field %s mismatch error = %v", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalIdentityOverridesApplied(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "create"}
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
SourceProductID: "src",
|
||||
ToolName: "create_thing",
|
||||
CLIName: "create",
|
||||
Group: "dev thing",
|
||||
CLIPath: "dev create",
|
||||
PrimaryCLIPath: "dev create",
|
||||
Aliases: []string{"dev alt"},
|
||||
Command: cmd,
|
||||
Source: "test",
|
||||
}
|
||||
final := contract.ContractFinalPayload{Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "dev",
|
||||
SourceProductID: "src",
|
||||
Name: "create_thing",
|
||||
CLIName: "create",
|
||||
CanonicalPath: "dev.create_thing",
|
||||
CLIPath: "dev create",
|
||||
PrimaryCLIPath: "dev create",
|
||||
Group: "dev thing",
|
||||
Aliases: []string{"dev alt"},
|
||||
Source: "test",
|
||||
}}
|
||||
spec, err := runtimeToolSpecFromContractFinal(entry, final, runtimeSchemaMetadataSources{})
|
||||
if err != nil {
|
||||
t.Fatalf("consistent declared identity with all override fields failed: %v", err)
|
||||
}
|
||||
if spec.Identity.SourceProductID != "src" {
|
||||
t.Fatalf("source_product_id = %q, want src", spec.Identity.SourceProductID)
|
||||
}
|
||||
if spec.Identity.Group != "dev thing" {
|
||||
t.Fatalf("group = %q, want dev thing", spec.Identity.Group)
|
||||
}
|
||||
if len(spec.Identity.Aliases) != 1 || spec.Identity.Aliases[0] != "dev alt" {
|
||||
t.Fatalf("aliases = %v, want [dev alt]", spec.Identity.Aliases)
|
||||
}
|
||||
if spec.Identity.CanonicalPath != "dev.create_thing" || spec.Identity.Source != "test" {
|
||||
t.Fatalf("identity = %#v", spec.Identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalSameLengthAliasMismatchFails(t *testing.T) {
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
ToolName: "create_thing",
|
||||
CLIName: "create",
|
||||
CLIPath: "dev create",
|
||||
PrimaryCLIPath: "dev create",
|
||||
Aliases: []string{"dev rm"},
|
||||
Command: &cobra.Command{Use: "create"},
|
||||
Source: "test",
|
||||
}
|
||||
// Same-length alias sets with different members must be detected by the
|
||||
// element-wise comparison, not only by the length fast-path.
|
||||
final := contract.ContractFinalPayload{Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "dev", Name: "create_thing", CanonicalPath: "dev.create_thing",
|
||||
CLIPath: "dev create", PrimaryCLIPath: "dev create",
|
||||
Aliases: []string{"dev other"},
|
||||
}}
|
||||
_, err := runtimeToolSpecFromContractFinal(entry, final, runtimeSchemaMetadataSources{})
|
||||
if err == nil || !strings.Contains(err.Error(), "aliases") {
|
||||
t.Fatalf("same-length alias mismatch error = %v, want aliases mismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalSafetyAnnotationFallbacks(t *testing.T) {
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
ToolName: "create_thing",
|
||||
CLIName: "create",
|
||||
CLIPath: "dev create",
|
||||
PrimaryCLIPath: "dev create",
|
||||
Source: "test",
|
||||
}
|
||||
|
||||
// Safety nil + Contract Risk annotation: Risk overlay wins.
|
||||
riskCmd := &cobra.Command{Use: "create"}
|
||||
riskCmd.Annotations = map[string]string{runtimeannotate.AnnotationRisk: "write"}
|
||||
entry.Command = riskCmd
|
||||
spec, err := runtimeToolSpecFromContractFinal(entry, contract.ContractFinalPayload{}, runtimeSchemaMetadataSources{})
|
||||
if err != nil {
|
||||
t.Fatalf("risk-annotated declared leaf failed: %v", err)
|
||||
}
|
||||
if spec.Safety.Effect != "write" || spec.Safety.Risk != "medium" || spec.Safety.Confirmation != "user_required" {
|
||||
t.Fatalf("risk fallback safety = %#v", spec.Safety)
|
||||
}
|
||||
if spec.Safety.EffectSource != "corecmd.contract" {
|
||||
t.Fatalf("risk fallback effect source = %q", spec.Safety.EffectSource)
|
||||
}
|
||||
|
||||
// Safety nil + runtime gate annotation: gate overlay wins.
|
||||
gateCmd := &cobra.Command{Use: "create"}
|
||||
gateCmd.Annotations = map[string]string{runtimeannotate.AnnotationRuntimeGate: "devAppRequireWriteGuard"}
|
||||
entry.Command = gateCmd
|
||||
spec, err = runtimeToolSpecFromContractFinal(entry, contract.ContractFinalPayload{}, runtimeSchemaMetadataSources{})
|
||||
if err != nil {
|
||||
t.Fatalf("gate-annotated declared leaf failed: %v", err)
|
||||
}
|
||||
if spec.Safety.Confirmation != "user_required" || spec.Safety.Effect != "write" || spec.Safety.Risk != "medium" {
|
||||
t.Fatalf("gate fallback safety = %#v", spec.Safety)
|
||||
}
|
||||
if spec.Safety.EffectSource != "corecmd.contract_gate" {
|
||||
t.Fatalf("gate fallback effect source = %q", spec.Safety.EffectSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalParameterResolutionError(t *testing.T) {
|
||||
oldParameters := resolveRuntimeParameters
|
||||
t.Cleanup(func() { resolveRuntimeParameters = oldParameters })
|
||||
resolveRuntimeParameters = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return nil, errors.New("parameters failed")
|
||||
}
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "dev",
|
||||
ToolName: "create_thing",
|
||||
Command: &cobra.Command{Use: "create"},
|
||||
}
|
||||
_, err := runtimeToolSpecFromContractFinal(entry, contract.ContractFinalPayload{}, runtimeSchemaMetadataSources{})
|
||||
if err == nil || !strings.Contains(err.Error(), "resolve Contract Schema parameters") {
|
||||
t.Fatalf("parameter resolution error = %v, want resolve Contract Schema parameters", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustFinal(t *testing.T, cmd *cobra.Command) contract.ContractFinalPayload {
|
||||
t.Helper()
|
||||
final, ok := contractfinal.RuntimeContractFinal(cmd)
|
||||
if !ok {
|
||||
t.Fatal("missing final")
|
||||
}
|
||||
return final
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageApplyContractRiskToSafety(t *testing.T) {
|
||||
base := contract.SafetySpec{Idempotency: "unknown", Risk: "low", Confirmation: "not_required", Effect: "read"}
|
||||
got := applyContractRiskToSafety(base, "high-risk-write")
|
||||
if got.Effect != "destructive" || got.Risk != "high" || got.Confirmation != "user_required" {
|
||||
t.Fatalf("high-risk overlay = %#v", got)
|
||||
}
|
||||
if got.Idempotency != "unknown" {
|
||||
t.Fatalf("idempotency should be preserved, got %q", got.Idempotency)
|
||||
}
|
||||
got = applyContractRiskToSafety(base, "write")
|
||||
if got.Effect != "write" || got.Risk != "medium" || got.Confirmation != "user_required" {
|
||||
t.Fatalf("write overlay = %#v", got)
|
||||
}
|
||||
got = applyContractRiskToSafety(base, "read")
|
||||
if got.Effect != "read" || got.Risk != "low" || got.Confirmation != "not_required" {
|
||||
t.Fatalf("read overlay = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyContractGateToSafety(t *testing.T) {
|
||||
base := contract.SafetySpec{Confirmation: "not_required", Effect: "read", Risk: "low"}
|
||||
got := applyContractGateToSafety(base, "devAppRequireWriteGuard")
|
||||
if got.Confirmation != "user_required" {
|
||||
t.Fatalf("gate must force user_required, got %#v", got)
|
||||
}
|
||||
if got.Effect != "write" || got.EffectSource != "corecmd.contract_gate" {
|
||||
t.Fatalf("gate effect overlay = %#v", got)
|
||||
}
|
||||
if got.Risk != "medium" {
|
||||
t.Fatalf("gate risk overlay = %#v", got)
|
||||
}
|
||||
reviewed := contract.SafetySpec{Confirmation: "not_required", Effect: "destructive", Risk: "high", EffectSource: "reviewed"}
|
||||
got = applyContractGateToSafety(reviewed, "devAppRequireWriteGuard")
|
||||
if got.Effect != "destructive" || got.Risk != "high" || got.Confirmation != "user_required" {
|
||||
t.Fatalf("gate must keep reviewed effect/risk: %#v", got)
|
||||
}
|
||||
if got = applyContractGateToSafety(base, " "); got != base {
|
||||
t.Fatalf("blank gate must be a no-op, got %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -5,130 +5,27 @@ package cli
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageCanonicalCommandsAndFlags(t *testing.T) {
|
||||
mcp := NewMCPCommand(t.Context(), nil, nil, nil)
|
||||
mcp := NewMCPCommand()
|
||||
mcp.SetOut(&bytes.Buffer{})
|
||||
if err := mcp.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
schema := map[string]any{"properties": map[string]any{
|
||||
"str": map[string]any{"type": "string", "description": " text "},
|
||||
"enum": map[string]any{"enum": []any{"a"}},
|
||||
"int": map[string]any{"type": "integer"},
|
||||
"num": map[string]any{"type": "number"},
|
||||
"bool": map[string]any{"type": "boolean"},
|
||||
"object": map[string]any{"type": "object"},
|
||||
"strings": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"enums": map[string]any{"type": "array", "items": map[string]any{"enum": []any{"x"}}},
|
||||
"ints": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}},
|
||||
"nums": map[string]any{"type": "array", "items": map[string]any{"type": "number"}},
|
||||
"bools": map[string]any{"type": "array", "items": map[string]any{"type": "boolean"}},
|
||||
"objects": map[string]any{"type": "array", "items": map[string]any{"type": "object"}},
|
||||
"unknown": map[string]any{"type": "future"},
|
||||
"array": map[string]any{"type": "array"},
|
||||
"invalid": "not schema",
|
||||
}}
|
||||
specs := BuildFlagSpecs(schema, map[string]CLIFlagHint{
|
||||
"str": {Alias: "string-alias", Shorthand: "s"},
|
||||
"int": {Alias: "int-alias", Shorthand: "i"},
|
||||
"num": {Alias: "num-alias", Shorthand: "n"},
|
||||
"bool": {Alias: "bool-alias", Shorthand: "b"},
|
||||
})
|
||||
if len(specs) != 13 {
|
||||
t.Fatalf("flag specs = %d, want 13", len(specs))
|
||||
}
|
||||
if BuildFlagSpecs(nil, nil) != nil || BuildFlagSpecs(map[string]any{}, nil) != nil {
|
||||
t.Fatal("missing properties produced flag specs")
|
||||
}
|
||||
cmd := &cobra.Command{Use: "flags"}
|
||||
cmd.Flags().String("occupied", "", "")
|
||||
cmd.Flags().StringP("short", "s", "", "")
|
||||
specs = append(specs,
|
||||
FlagSpec{PropertyName: "skip", FlagName: "json", Kind: flagString},
|
||||
FlagSpec{PropertyName: "same", FlagName: "same", Alias: "same", Kind: flagString},
|
||||
FlagSpec{PropertyName: "occupied", FlagName: "occupied", Kind: flagString},
|
||||
FlagSpec{PropertyName: "default", FlagName: "default", Kind: flagString},
|
||||
)
|
||||
applyFlagSpecs(cmd, specs)
|
||||
applyFlagSpecs(cmd, []FlagSpec{{PropertyName: "array-alias", FlagName: "array-alias", Alias: "array-hidden", Kind: flagStringArray}})
|
||||
for _, name := range []string{"str", "string-alias", "int", "int-alias", "num", "num-alias", "bool", "bool-alias", "strings", "ints", "nums", "bools", "objects", "array", "default"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("flag %q not registered", name)
|
||||
}
|
||||
}
|
||||
if nested, ok := nestedMap(map[string]any{"x": map[string]any{"y": true}}, "x"); !ok || !nested["y"].(bool) {
|
||||
t.Fatal("nested map lookup failed")
|
||||
}
|
||||
if _, ok := nestedMap(nil, "x"); ok {
|
||||
t.Fatal("nil nested map matched")
|
||||
}
|
||||
if _, ok := nestedMap(map[string]any{}, "x"); ok {
|
||||
t.Fatal("missing nested map matched")
|
||||
}
|
||||
if _, ok := nestedMap(map[string]any{"x": 1}, "x"); ok {
|
||||
t.Fatal("non-map nested value matched")
|
||||
}
|
||||
if schemaDescription(map[string]any{"description": 1}) != "" {
|
||||
t.Fatal("non-string description rendered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLoaderAndPriorityEdges(t *testing.T) {
|
||||
for _, reason := range []CatalogDegradedReason{DegradedUnauthenticated, DegradedMarketUnreachable, DegradedRuntimeAllFailed, "other"} {
|
||||
degraded := newCatalogDegraded(reason, 2)
|
||||
if degraded.Error() == "" || degradedHint(reason, 2) == "" || degraded.Hint == "" {
|
||||
t.Fatalf("degraded %q = %#v", reason, degraded)
|
||||
}
|
||||
}
|
||||
catalog := Catalog{Products: []CanonicalProduct{{ID: "product", Tools: []ToolDescriptor{{RPCName: "tool"}}}}}
|
||||
product, ok := catalog.FindProduct("product")
|
||||
if !ok {
|
||||
t.Fatal("product not found")
|
||||
}
|
||||
if _, ok := catalog.FindProduct("missing"); ok {
|
||||
t.Fatal("missing product found")
|
||||
}
|
||||
if _, ok := product.FindTool("tool"); !ok {
|
||||
t.Fatal("tool not found")
|
||||
}
|
||||
if _, ok := product.FindTool("missing"); ok {
|
||||
t.Fatal("missing tool found")
|
||||
}
|
||||
if got, err := (StaticLoader{Catalog: catalog}).Load(t.Context()); err != nil || len(got.Products) != 1 {
|
||||
t.Fatalf("static load = %#v, %v", got, err)
|
||||
}
|
||||
failure := errors.New("load failed")
|
||||
if got, err := CatalogLoaderFrom(catalog, failure).Load(t.Context()); !errors.Is(err, failure) || len(got.Products) != 1 {
|
||||
t.Fatalf("preloaded load = %#v, %v", got, err)
|
||||
}
|
||||
loader := NewEnvironmentLoader()
|
||||
if got, err := loader.Load(t.Context()); err != nil || len(got.Products) != 0 {
|
||||
t.Fatalf("environment load = %#v, %v", got, err)
|
||||
}
|
||||
func TestCrossPlatformCoveragePriorityEdges(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "priority"}
|
||||
SetOverridePriority(cmd, 42)
|
||||
if got := OverridePriority(cmd); got != 42 {
|
||||
t.Fatalf("priority = %d", got)
|
||||
}
|
||||
original := edition.Get()
|
||||
edition.Override(&edition.Hooks{IsEmbedded: true})
|
||||
t.Cleanup(func() { edition.Override(original) })
|
||||
for _, reason := range []CatalogDegradedReason{DegradedUnauthenticated, DegradedMarketUnreachable, DegradedRuntimeAllFailed} {
|
||||
if degradedHint(reason, 2) == "" {
|
||||
t.Fatalf("embedded degraded hint %q is empty", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaValidationCompleteMatrix(t *testing.T) {
|
||||
@@ -214,13 +111,29 @@ func TestCrossPlatformCoverageSchemaValidationCompleteMatrix(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageStdinCompleteMatrix(t *testing.T) {
|
||||
original := os.Stdin
|
||||
t.Cleanup(func() { os.Stdin = original })
|
||||
// Restore/close before t.TempDir cleanup. defer runs on return and on
|
||||
// t.Fatal (Goexit); open Windows handles otherwise block RemoveAll.
|
||||
defer func() {
|
||||
if current := os.Stdin; current != nil && current != original {
|
||||
os.Stdin = original
|
||||
_ = current.Close()
|
||||
return
|
||||
}
|
||||
os.Stdin = original
|
||||
}()
|
||||
setStdin := func(f *os.File) {
|
||||
if prev := os.Stdin; prev != nil && prev != original {
|
||||
_ = prev.Close()
|
||||
}
|
||||
os.Stdin = f
|
||||
}
|
||||
|
||||
closed, err := os.CreateTemp(t.TempDir(), "closed")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = closed.Close()
|
||||
os.Stdin = closed
|
||||
setStdin(closed)
|
||||
if StdinIsPipe() {
|
||||
t.Fatal("closed stdin reported as pipe")
|
||||
}
|
||||
@@ -236,7 +149,7 @@ func TestCrossPlatformCoverageStdinCompleteMatrix(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = stdin.Seek(0, 0)
|
||||
os.Stdin = stdin
|
||||
setStdin(stdin)
|
||||
if !StdinIsPipe() {
|
||||
t.Fatal("regular file stdin did not report pipe")
|
||||
}
|
||||
@@ -270,8 +183,7 @@ func TestCrossPlatformCoverageStdinCompleteMatrix(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer largeStdin.Close()
|
||||
os.Stdin = largeStdin
|
||||
setStdin(largeStdin)
|
||||
if _, err := readStdinBounded(); err == nil {
|
||||
t.Fatal("oversized stdin accepted")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Test helpers for this package's lazy delivery state. Production code must
|
||||
// not call these; the ForTest suffix is the boundary. Cross-package tests use
|
||||
// the exported ones; in-package tests may use the unexported resetters.
|
||||
|
||||
func resetDeliverySchemaCatalogStateForTest() {
|
||||
resetDeliverySchemaCatalogState()
|
||||
}
|
||||
|
||||
func resetMetaByCLIPathStateForTest() {
|
||||
resetSchemaDeliveryState()
|
||||
}
|
||||
|
||||
func resetSchemaSourceRootAtomicForTest() {
|
||||
schemaSourceRoot = atomic.Value{}
|
||||
}
|
||||
|
||||
// clearDeclaredDryRunCapabilitiesForTest resets the declared index (tests only).
|
||||
func clearDeclaredDryRunCapabilitiesForTest() {
|
||||
declaredDryRunCapabilities.Range(func(key, _ any) bool {
|
||||
declaredDryRunCapabilities.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func resetReviewedDryRunCapabilitiesLazyForTest() {
|
||||
resetReviewedDryRunCapabilitiesLazy()
|
||||
}
|
||||
|
||||
func setReviewedDryRunCapabilityGroupsForTest(groups []dryRunCapabilityGroup) func() {
|
||||
previous := reviewedDryRunCapabilityGroups
|
||||
reviewedDryRunCapabilityGroups = groups
|
||||
resetReviewedDryRunCapabilitiesLazy()
|
||||
return func() {
|
||||
reviewedDryRunCapabilityGroups = previous
|
||||
resetReviewedDryRunCapabilitiesLazy()
|
||||
}
|
||||
}
|
||||
|
||||
// InstallProductionSchemaAssemblyForTest installs the production
|
||||
// assembleSchemaCatalogFromRoot delivery path with factory and clears lazy
|
||||
// caches. Tests must Cleanup via RegisterSchemaSourceRoot(nil) or a restore
|
||||
// helper that reinstalls their prior delivery stub.
|
||||
func InstallProductionSchemaAssemblyForTest(factory func() *cobra.Command) {
|
||||
RegisterSchemaSourceRoot(factory)
|
||||
assembleDeliverySchemaCatalogFn = assembleSchemaCatalogFromRoot
|
||||
resetDeliverySchemaCatalogStateForTest()
|
||||
}
|
||||
|
||||
// DeliverySchemaAllPayloadForTest returns schema --all through the installed
|
||||
// delivery loader (catalog_hash comes from Snapshot.SourceHash).
|
||||
func DeliverySchemaAllPayloadForTest() (map[string]any, error) {
|
||||
return deliverySchemaAllPayload()
|
||||
}
|
||||
|
||||
// RestorePackageCLISchemaDeliveryForTest reinstalls the package-cli TestMain
|
||||
// assembled-delivery stub after a production-assembly exercise. Outside package
|
||||
// cli TestMain it clears the factory and resets lazy delivery state.
|
||||
func RestorePackageCLISchemaDeliveryForTest() {
|
||||
if restorePackageCLISchemaDeliveryHook != nil {
|
||||
restorePackageCLISchemaDeliveryHook()
|
||||
return
|
||||
}
|
||||
storeSchemaSourceRootFn(nil)
|
||||
assembleDeliverySchemaCatalogFn = assembleSchemaCatalogFromRoot
|
||||
resetSchemaDeliveryState()
|
||||
}
|
||||
|
||||
// restorePackageCLISchemaDeliveryHook is installed by package-cli TestMain so
|
||||
// external cli_test helpers can restore the assembled-delivery stub.
|
||||
var restorePackageCLISchemaDeliveryHook func()
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestProductionCodeDoesNotCallForTestHelpers enforces the fortest.go boundary:
|
||||
// production (non-test) code must not call symbols whose names end in ForTest.
|
||||
// Definitions may live in fortest.go; *_test.go may call them freely.
|
||||
func TestProductionCodeDoesNotCallForTestHelpers(t *testing.T) {
|
||||
root := moduleRoot(t)
|
||||
fset := token.NewFileSet()
|
||||
var violations []string
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
name := d.Name()
|
||||
switch name {
|
||||
case ".git", "vendor", "node_modules", ".worktrees", "artifacts", "dist", "target":
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
base := filepath.Base(path)
|
||||
if strings.HasSuffix(base, "_test.go") || base == "fortest.go" {
|
||||
return nil
|
||||
}
|
||||
rel, relErr := filepath.Rel(root, path)
|
||||
if relErr != nil {
|
||||
rel = path
|
||||
}
|
||||
file, parseErr := parser.ParseFile(fset, path, nil, 0)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
name := calledIdentName(call.Fun)
|
||||
if name == "" || !strings.HasSuffix(name, "ForTest") {
|
||||
return true
|
||||
}
|
||||
pos := fset.Position(call.Pos())
|
||||
violations = append(violations, rel+":"+strconv.Itoa(pos.Line)+": call to "+name)
|
||||
return true
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk module: %v", err)
|
||||
}
|
||||
if len(violations) > 0 {
|
||||
t.Fatalf("production code must not call *ForTest helpers:\n %s", strings.Join(violations, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
func moduleRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getcwd: %v", err)
|
||||
}
|
||||
dir := wd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
t.Fatal("go.mod not found above package directory")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
func calledIdentName(fun ast.Expr) string {
|
||||
switch v := fun.(type) {
|
||||
case *ast.Ident:
|
||||
return v.Name
|
||||
case *ast.SelectorExpr:
|
||||
return v.Sel.Name
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
+21
-21
@@ -11,31 +11,31 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// gen.go is the single entry point for reviewed CLI asset generation. It isolates
|
||||
// all //go:generate pragmas from business code so that:
|
||||
// - schema_agent_metadata.go / schema_catalog.go contain only types + embed.
|
||||
// - Generation is a standalone process (make generate-schema triggers this).
|
||||
// - The authored-input → generated-output contract is documented in one place.
|
||||
// gen.go isolates //go:generate pragmas from business code.
|
||||
//
|
||||
// Generation inputs (authored, reviewed):
|
||||
// 1. schema_command_registry/ identity (canonical/aliases/navigation)
|
||||
// 2. schema_hints/metadata/*.json safety (effect/risk/confirmation)
|
||||
// 3. schema_hints/selection/*.json selection (use_when/avoid_when)
|
||||
// 4. schema_mcp_metadata.json MCP server tool definitions
|
||||
// 5. schema_parameter_bindings.json parameter type/property mappings
|
||||
// 6. param_concepts.json + schema reviewed parameter synonym policy
|
||||
// 7. cobra command tree (Go runtime) flags/usage/required (reflected)
|
||||
// Schema Catalog delivery is NOT generated here. Production assembles Catalog
|
||||
// at runtime via ResolveSchemaBuild (声明即 Catalog); see schema_source_root.go
|
||||
// and internal/app/schema_source_register.go. cmd_schema_catalog remains a
|
||||
// CI/determinism tool (make generate-schema / check-generated-drift) and must
|
||||
// not be a committed delivery step.
|
||||
//
|
||||
// Generation outputs (embedded at build):
|
||||
// - schema_agent_metadata/*.json per-product agent metadata
|
||||
// - schema_catalog/ per-product catalog shards
|
||||
// Inputs consumed by runtime assembly:
|
||||
// 1. leaf ContractFinal.Identity identity (canonical/aliases/navigation),
|
||||
// collected from the live Cobra tree
|
||||
// 2. contract.ProductDecl + leaf ContractFinal Agent routing / selection prose
|
||||
// (Interface / ParamDecl own interface facts)
|
||||
// 3. schema_parameter_mapping_ledger.go mapping exclusions / removals (Go)
|
||||
// (active bindings retired; ParamDecl.Property owns delivery)
|
||||
// 4. param_concepts.json + schema reviewed parameter synonym policy
|
||||
// 5. cobra command tree (Go runtime) flags/usage/required (reflected)
|
||||
//
|
||||
// schema_hints/, schema_agent_metadata/, schema_command_registry/,
|
||||
// schema_mcp_metadata.json, and schema_mcp_service_review(.json|/ledger) are
|
||||
// retired.
|
||||
//
|
||||
// Remaining generated output from this file:
|
||||
// - param_aliases_generated.go per-command parameter normalization
|
||||
|
||||
package cli
|
||||
|
||||
//go:generate go run ../generator/cmd_schema_agent_metadata -root ../.. -registry internal/cli/schema_command_registry -output-dir schema_agent_metadata -audit-output schema_agent_metadata_audit.json
|
||||
// Rebuild all dependencies so the Catalog compiler cannot reuse the cli
|
||||
// package cached by the preceding metadata generator with the old embedded
|
||||
// JSON files.
|
||||
//go:generate go run -a ../generator/cmd_schema_catalog -root ../.. -output schema_catalog
|
||||
//go:generate go run ../generator/cmd_param_aliases -root ../.. -output param_aliases_generated.go
|
||||
|
||||
@@ -48,7 +48,6 @@ func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
t.Fatalf("read gen.go: %v", err)
|
||||
}
|
||||
for _, generator := range []string{
|
||||
"cmd_schema_agent_metadata",
|
||||
"cmd_schema_catalog",
|
||||
"cmd_param_aliases",
|
||||
} {
|
||||
@@ -56,4 +55,13 @@ func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
t.Errorf("gen.go does not register %s", generator)
|
||||
}
|
||||
}
|
||||
if bytes.Contains(content, []byte("cmd_schema_agent_metadata")) {
|
||||
t.Error("gen.go must not regenerate retired schema_agent_metadata/")
|
||||
}
|
||||
for _, line := range bytes.Split(content, []byte("\n")) {
|
||||
trimmed := strings.TrimSpace(string(line))
|
||||
if strings.HasPrefix(trimmed, "//go:generate") && strings.Contains(trimmed, "schema_agent_metadata") {
|
||||
t.Errorf("go:generate must not target schema_agent_metadata: %s", trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package homology holds Schema/CLI homology and ContractFinal consistency
|
||||
// gates that exercise the live command tree and delivered Catalog.
|
||||
//
|
||||
// These tests intentionally live outside the cli root so contract / homology
|
||||
// policy noise does not clutter the delivery package surface. They import
|
||||
// internal/cli and related packages; they do not redefine delivery types.
|
||||
package homology
|
||||
@@ -0,0 +1,239 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package homology
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
)
|
||||
|
||||
// TestAllCommandsContractFinalConsistentWithLiveCatalog walks every reviewed
|
||||
// registry leaf and checks, one by one:
|
||||
// 1. PrimaryCommand has runtime ContractFinal (command-side declaration)
|
||||
// 2. Live assembly ToolSpec fields match that ContractFinal
|
||||
func TestAllCommandsContractFinalConsistentWithLiveCatalog(t *testing.T) {
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
liveReg, err := cli.AssembleSchemaRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("AssembleSchemaRegistry(live) error = %v", err)
|
||||
}
|
||||
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("BindEffectiveCommandRegistry() error = %v", err)
|
||||
}
|
||||
|
||||
liveByCanon := indexLiveTools(liveReg)
|
||||
|
||||
type row struct {
|
||||
canonical string
|
||||
cliPath string
|
||||
status string
|
||||
detail string
|
||||
}
|
||||
rows := make([]row, 0, len(bound.Commands))
|
||||
var failCount int
|
||||
checked := 0
|
||||
|
||||
canonOrder := make([]string, 0, len(bound.Commands))
|
||||
boundByCanon := make(map[string]cli.BoundCommandSpec, len(bound.Commands))
|
||||
for _, cmd := range bound.Commands {
|
||||
if cmd.CanonicalPath == "" {
|
||||
continue
|
||||
}
|
||||
canonOrder = append(canonOrder, cmd.CanonicalPath)
|
||||
boundByCanon[cmd.CanonicalPath] = cmd
|
||||
}
|
||||
sort.Strings(canonOrder)
|
||||
|
||||
for _, canonical := range canonOrder {
|
||||
cmd := boundByCanon[canonical]
|
||||
cliPath := cmd.PrimaryCLIPath
|
||||
liveTool, ok := liveByCanon[canonical]
|
||||
if !ok {
|
||||
failCount++
|
||||
rows = append(rows, row{canonical, cliPath, "FAIL", "absent from live registry"})
|
||||
continue
|
||||
}
|
||||
final, has := contractfinal.RuntimeContractFinal(cmd.PrimaryCommand)
|
||||
if !has {
|
||||
failCount++
|
||||
rows = append(rows, row{canonical, cliPath, "FAIL", "no contractfinal.RuntimeContractFinal on PrimaryCommand"})
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
problems := compareFinalToLive(canonical, final, liveTool)
|
||||
if len(problems) > 0 {
|
||||
failCount++
|
||||
rows = append(rows, row{canonical, cliPath, "FAIL", strings.Join(problems, "; ")})
|
||||
continue
|
||||
}
|
||||
if liveTool.MetadataSource != "corecmd.contract" {
|
||||
failCount++
|
||||
rows = append(rows, row{canonical, cliPath, "FAIL", "metadata_source=" + liveTool.MetadataSource})
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row{canonical, cliPath, "OK", "ContractFinal=live=catalog"})
|
||||
}
|
||||
|
||||
reportPath := filepath.Join(t.TempDir(), "contract_final_per_command.txt")
|
||||
var b strings.Builder
|
||||
for i, r := range rows {
|
||||
line := fmt.Sprintf("%04d %s %-48s %-60s %s\n", i+1, r.status, r.canonical, r.cliPath, r.detail)
|
||||
b.WriteString(line)
|
||||
if r.status != "OK" {
|
||||
t.Errorf("%s", strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
summary := fmt.Sprintf("SUMMARY checked=%d fail=%d ok=%d live=%d\n",
|
||||
checked, failCount, checked-failCount, len(liveByCanon))
|
||||
b.WriteString(summary)
|
||||
if err := os.WriteFile(reportPath, []byte(b.String()), 0o644); err != nil {
|
||||
t.Fatalf("write report: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("per-command report: %s", reportPath)
|
||||
t.Logf("%s", strings.TrimSpace(summary))
|
||||
// Print every line so -v shows 逐个检查.
|
||||
for _, r := range rows {
|
||||
t.Logf("%s %-48s %s", r.status, r.canonical, r.cliPath)
|
||||
}
|
||||
|
||||
if failCount > 0 {
|
||||
t.Fatalf("per-command consistency failed: %d/%d", failCount, len(rows))
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("no bound commands with ContractFinal were checked against live catalog")
|
||||
}
|
||||
if err := cli.ValidateRuntimeSchemaCompleteness(app.NewSchemaSourceRootCommand()); err != nil {
|
||||
t.Fatalf("ValidateRuntimeSchemaCompleteness() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type liveToolView struct {
|
||||
Description string
|
||||
MetadataSource string
|
||||
Effect string
|
||||
Risk string
|
||||
Confirmation string
|
||||
Idempotency string
|
||||
InterfaceMode string
|
||||
Availability string
|
||||
InterfacePID string
|
||||
InterfaceRPC string
|
||||
InterfaceReason string
|
||||
AgentSummary string
|
||||
UseWhen []string
|
||||
AvoidWhen []string
|
||||
Examples []string
|
||||
}
|
||||
|
||||
func indexLiveTools(reg cli.SchemaRegistry) map[string]liveToolView {
|
||||
out := make(map[string]liveToolView)
|
||||
for _, product := range reg.Products {
|
||||
for _, tool := range product.Tools {
|
||||
view := liveToolView{
|
||||
Description: tool.Description,
|
||||
MetadataSource: tool.MetadataSource,
|
||||
Effect: tool.Safety.Effect,
|
||||
Risk: tool.Safety.Risk,
|
||||
Confirmation: tool.Safety.Confirmation,
|
||||
Idempotency: tool.Safety.Idempotency,
|
||||
InterfaceMode: tool.Interface.Mode,
|
||||
Availability: tool.Interface.Availability,
|
||||
InterfaceReason: tool.Interface.Reason,
|
||||
AgentSummary: tool.Selection.AgentSummary,
|
||||
UseWhen: append([]string(nil), tool.Selection.UseWhen...),
|
||||
AvoidWhen: append([]string(nil), tool.Selection.AvoidWhen...),
|
||||
Examples: append([]string(nil), tool.Selection.Examples...),
|
||||
}
|
||||
if tool.Interface.Ref != nil {
|
||||
view.InterfacePID = tool.Interface.Ref.ProductID
|
||||
view.InterfaceRPC = tool.Interface.Ref.RPCName
|
||||
}
|
||||
out[tool.Identity.CanonicalPath] = view
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func compareFinalToLive(canonical string, final contract.ContractFinalPayload, tool liveToolView) []string {
|
||||
var problems []string
|
||||
add := func(field, want, got string) {
|
||||
if strings.TrimSpace(want) == "" {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(want) != strings.TrimSpace(got) {
|
||||
problems = append(problems, fmt.Sprintf("%s: final=%q live=%q", field, want, got))
|
||||
}
|
||||
}
|
||||
// Description is intentionally not compared: ContractFinal stores the
|
||||
// authored ContractDecl.Description, while Catalog delivery prefers Cobra
|
||||
// Long when present (provenance cobra_help). live↔embed still checks the
|
||||
// delivered description string.
|
||||
_ = final.Description
|
||||
_ = tool.Description
|
||||
if final.Safety != nil {
|
||||
add("effect", final.Safety.Effect, tool.Effect)
|
||||
add("risk", final.Safety.Risk, tool.Risk)
|
||||
add("confirmation", final.Safety.Confirmation, tool.Confirmation)
|
||||
add("idempotency", final.Safety.Idempotency, tool.Idempotency)
|
||||
}
|
||||
if final.Interface != nil {
|
||||
add("interface_mode", final.Interface.Mode, tool.InterfaceMode)
|
||||
add("availability", final.Interface.Availability, tool.Availability)
|
||||
add("interface_reason", final.Interface.Reason, tool.InterfaceReason)
|
||||
if final.Interface.Ref != nil {
|
||||
add("interface_product", final.Interface.Ref.ProductID, tool.InterfacePID)
|
||||
add("interface_rpc", final.Interface.Ref.RPCName, tool.InterfaceRPC)
|
||||
}
|
||||
}
|
||||
if final.Selection != nil {
|
||||
add("agent_summary", final.Selection.AgentSummary, tool.AgentSummary)
|
||||
if !reflect.DeepEqual(final.Selection.UseWhen, tool.UseWhen) {
|
||||
problems = append(problems, "use_when mismatch")
|
||||
}
|
||||
if !reflect.DeepEqual(final.Selection.AvoidWhen, tool.AvoidWhen) {
|
||||
problems = append(problems, "avoid_when mismatch")
|
||||
}
|
||||
if !reflect.DeepEqual(final.Selection.Examples, tool.Examples) {
|
||||
problems = append(problems, "examples mismatch")
|
||||
}
|
||||
}
|
||||
_ = canonical
|
||||
return problems
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func asStringSlice(v any) []string {
|
||||
raw, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package homology
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
)
|
||||
|
||||
func TestContractFinalFoundForHrbrain(t *testing.T) {
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
hrbrain, _, err := root.Find([]string{"hrbrain", "search", "employees"})
|
||||
if err != nil || hrbrain == nil {
|
||||
t.Fatalf("hrbrain search employees not found: %v", err)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(hrbrain)
|
||||
if !ok {
|
||||
t.Fatal("ContractFinal NOT found for hrbrain search employees — this is the root cause of contract.ParamDecl not working")
|
||||
}
|
||||
t.Logf("ContractFinal found: title=%s params=%d", final.Title, len(final.Parameters))
|
||||
}
|
||||
|
||||
func TestContractFinalFoundForAisearch(t *testing.T) {
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
enterprise, _, err := root.Find([]string{"aisearch", "enterprise"})
|
||||
if err != nil || enterprise == nil {
|
||||
t.Fatalf("aisearch enterprise not found: %v", err)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(enterprise)
|
||||
if !ok {
|
||||
t.Fatal("ContractFinal NOT found for aisearch enterprise")
|
||||
}
|
||||
t.Logf("ContractFinal found: title=%s params=%d", final.Title, len(final.Parameters))
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package homology
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Planned homology gates (docs/flag-help-schema-homology.md §4). Implementations
|
||||
// land incrementally; this test pins the gate ID vocabulary so CI docs and
|
||||
// future policy scripts share one checklist.
|
||||
var plannedHomologyGateIDs = []string{
|
||||
"HOM-P1", // schema parameters names ≡ cobra local flags
|
||||
"HOM-P2", // type/required/default ≡ Contract/cobra; hints cannot win those
|
||||
"HOM-P3", // schema constraints ≡ Contract Constraints projection
|
||||
"HOM-S1", // Risk write/high-risk → confirmation=user_required + help Safety
|
||||
"HOM-S2", // write-guard path must record honest provenance
|
||||
"HOM-S3", // read Risk must not project user_required without exclusion
|
||||
"HOM-I1", // interface_ref/bindings ⊆ Contract flags; MCP meta creates no flags
|
||||
"HOM-D1", // --help Flags ≡ schema leaf parameters
|
||||
}
|
||||
|
||||
// ciHomologyEntrypoints are executable gates already wired into
|
||||
// scripts/policy/check-schema-catalog.sh (and confirmation-truth helper).
|
||||
// Vocabulary-only pinning is not enough: these names must stay on the policy
|
||||
// -run whitelist.
|
||||
var ciHomologyEntrypoints = []string{
|
||||
"TestUserRequiredSafetyHomologyWithRuntimeGate",
|
||||
"TestHomologyDecisionDocPinsPathAAndGateIDs",
|
||||
"TestMCPPassthroughAdmissionExcludesLeafAndShortcut",
|
||||
"TestHomologyCIEntrypointsPinned",
|
||||
"TestFinalSchemaParametersMatchExecutableHelpFlags",
|
||||
"TestDeliverySchemaParametersMatchExecutableHelpFlags",
|
||||
"TestSheetFinalSchemaConfirmationMatchesRuntimeGuards",
|
||||
}
|
||||
|
||||
func TestHomologyDecisionDocPinsPathAAndGateIDs(t *testing.T) {
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
doc := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "docs", "flag-help-schema-homology.md"))
|
||||
raw, err := os.ReadFile(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("read homology doc: %v", err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, needle := range []string{
|
||||
"路径 A",
|
||||
"Contract / LeafSpec 为 CLI 表面权威",
|
||||
"必须嵌入进 Schema",
|
||||
"embedContractIntoSchema",
|
||||
"声明 = 最终数据源",
|
||||
"不得:",
|
||||
"把声明序列化成 JSON 注解再解析",
|
||||
"声明体系里再挂「评审字段」并行权威",
|
||||
"声明(declare):写什么、写在哪、投影到哪",
|
||||
"契约字段(算声明)",
|
||||
"编排 / 执行字段(不算声明)",
|
||||
"Schema 全覆盖(`ToolSpec` 无空洞)",
|
||||
"dws.schema.runtime_gate",
|
||||
"mcp_passthrough",
|
||||
"不得覆盖 LeafSpec / Shortcut 主路径",
|
||||
"已进 CI",
|
||||
"check-schema-catalog.sh",
|
||||
} {
|
||||
if !strings.Contains(body, needle) {
|
||||
t.Fatalf("homology doc missing %q", needle)
|
||||
}
|
||||
}
|
||||
for _, id := range plannedHomologyGateIDs {
|
||||
if !strings.Contains(body, id) {
|
||||
t.Fatalf("homology doc missing planned gate ID %s", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPPassthroughAdmissionExcludesLeafAndShortcut(t *testing.T) {
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
doc := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "docs", "flag-help-schema-homology.md"))
|
||||
raw, err := os.ReadFile(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("read homology doc: %v", err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, needle := range []string{
|
||||
"严格 1:1",
|
||||
"全部 `LeafSpec` 命令",
|
||||
"全部 `+shortcut`",
|
||||
"surface_kind=mcp_passthrough",
|
||||
} {
|
||||
if !strings.Contains(body, needle) {
|
||||
t.Fatalf("passthrough admission text missing %q", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomologyCIEntrypointsPinned(t *testing.T) {
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", ".."))
|
||||
script := filepath.Join(root, "scripts", "policy", "check-schema-catalog.sh")
|
||||
raw, err := os.ReadFile(script)
|
||||
if err != nil {
|
||||
t.Fatalf("read check-schema-catalog.sh: %v", err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, name := range ciHomologyEntrypoints {
|
||||
if !strings.Contains(body, name) {
|
||||
t.Fatalf("check-schema-catalog.sh missing homology CI entrypoint %q", name)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "./internal/cli/homology") {
|
||||
t.Fatal("check-schema-catalog.sh must run ./internal/cli/homology for HOM gate tests")
|
||||
}
|
||||
doc := filepath.Join(root, "docs", "flag-help-schema-homology.md")
|
||||
docRaw, err := os.ReadFile(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("read homology doc: %v", err)
|
||||
}
|
||||
docBody := string(docRaw)
|
||||
for _, name := range []string{
|
||||
"TestUserRequiredSafetyHomologyWithRuntimeGate",
|
||||
"TestFinalSchemaParametersMatchExecutableHelpFlags",
|
||||
"TestHomologyCIEntrypointsPinned",
|
||||
} {
|
||||
if !strings.Contains(docBody, name) {
|
||||
t.Fatalf("homology doc §3 CI table missing %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package homology
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
)
|
||||
|
||||
func TestParamDeclAnnotationCompare(t *testing.T) {
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
for _, path := range [][]string{
|
||||
{"aisearch", "enterprise"},
|
||||
{"hrbrain", "search", "employees"},
|
||||
{"chat", "add-emoji"},
|
||||
} {
|
||||
cmd, _, err := root.Find(path)
|
||||
if err != nil || cmd == nil {
|
||||
t.Logf("%v: NOT FOUND", path)
|
||||
continue
|
||||
}
|
||||
ann := cmd.Annotations["dws.schema.param_decls"]
|
||||
if ann == "" {
|
||||
t.Logf("%v: NO param_decls annotation", path)
|
||||
} else {
|
||||
t.Logf("%v: HAS param_decls (%d bytes)", path, len(ann))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package homology
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
)
|
||||
|
||||
// TestUserRequiredSafetyHomologyWithRuntimeGate proves Catalog Safety and the
|
||||
// executable confirmation gate share one source for every live user_required leaf:
|
||||
//
|
||||
// 1. ContractFinal.Safety.Confirmation == live ToolSpec.Confirmation
|
||||
// 2. Runtime gate is installed: DeclareLeafMetadata (HasContractConfirmSafety),
|
||||
// Sheet protect marker, or framework NewCommand/Shortcut RunE (verified by
|
||||
// closed-stdin Execute → confirmation_required / 用户取消了操作 without --yes)
|
||||
func TestUserRequiredSafetyHomologyWithRuntimeGate(t *testing.T) {
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
if root.PersistentFlags().Lookup("yes") == nil {
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
}
|
||||
if root.PersistentFlags().Lookup("dry-run") == nil {
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
}
|
||||
|
||||
liveReg, err := cli.AssembleSchemaRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("AssembleSchemaRegistry: %v", err)
|
||||
}
|
||||
effective, err := cli.BuildEffectiveCommandRegistry(root)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildEffectiveCommandRegistry: %v", err)
|
||||
}
|
||||
bound, err := cli.BindEffectiveCommandRegistry(root, effective)
|
||||
if err != nil {
|
||||
t.Fatalf("BindEffectiveCommandRegistry: %v", err)
|
||||
}
|
||||
|
||||
liveByCanon := make(map[string]string) // canonical → confirmation
|
||||
liveToolByCanon := make(map[string]cli.ToolSpec)
|
||||
for _, product := range liveReg.Products {
|
||||
for _, tool := range product.Tools {
|
||||
liveByCanon[tool.Identity.CanonicalPath] = strings.TrimSpace(tool.Safety.Confirmation)
|
||||
liveToolByCanon[tool.Identity.CanonicalPath] = tool
|
||||
}
|
||||
}
|
||||
|
||||
type row struct {
|
||||
canonical string
|
||||
cliPath string
|
||||
gate string
|
||||
detail string
|
||||
}
|
||||
var fails []row
|
||||
var okRows []row
|
||||
checked := 0
|
||||
|
||||
canonOrder := make([]string, 0, len(bound.Commands))
|
||||
boundByCanon := make(map[string]cli.BoundCommandSpec, len(bound.Commands))
|
||||
for _, cmd := range bound.Commands {
|
||||
if cmd.CanonicalPath == "" {
|
||||
continue
|
||||
}
|
||||
canonOrder = append(canonOrder, cmd.CanonicalPath)
|
||||
boundByCanon[cmd.CanonicalPath] = cmd
|
||||
}
|
||||
sort.Strings(canonOrder)
|
||||
|
||||
for _, canonical := range canonOrder {
|
||||
cmd := boundByCanon[canonical]
|
||||
leaf := cmd.PrimaryCommand
|
||||
if leaf == nil {
|
||||
fails = append(fails, row{canonical, cmd.PrimaryCLIPath, "", "nil PrimaryCommand"})
|
||||
continue
|
||||
}
|
||||
final, hasFinal := contractfinal.RuntimeContractFinal(leaf)
|
||||
if !hasFinal || final.Safety == nil {
|
||||
continue
|
||||
}
|
||||
conf := strings.TrimSpace(final.Safety.Confirmation)
|
||||
if conf != "user_required" {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
cliPath := cmd.PrimaryCLIPath
|
||||
|
||||
liveConf, ok := liveByCanon[canonical]
|
||||
if !ok {
|
||||
fails = append(fails, row{canonical, cliPath, "", "absent from live registry"})
|
||||
continue
|
||||
}
|
||||
if liveConf != "user_required" {
|
||||
fails = append(fails, row{canonical, cliPath, "", fmt.Sprintf(
|
||||
"ContractFinal=user_required but live ToolSpec.Confirmation=%q", liveConf)})
|
||||
continue
|
||||
}
|
||||
|
||||
gate := ""
|
||||
switch {
|
||||
case helpers.HasContractConfirmSafety(leaf) && helpers.HasSheetMutationConfirmationGuard(leaf):
|
||||
gate = "declare_leaf+sheet_marker"
|
||||
case helpers.HasContractConfirmSafety(leaf):
|
||||
gate = "declare_leaf_confirm"
|
||||
case helpers.HasSheetMutationConfirmationGuard(leaf):
|
||||
gate = "sheet_protect"
|
||||
default:
|
||||
// NewLeafCommand / Shortcut: NewCommand registers ContractFinal from the
|
||||
// same contract.SafetySpec it wires into ConfirmSafety. Prove that contract.SafetySpec is
|
||||
// still user_required-operable (closed stdin → confirmation gate).
|
||||
leaf.SetIn(strings.NewReader(""))
|
||||
leaf.SetOut(io.Discard)
|
||||
leaf.SetErr(io.Discard)
|
||||
if err := corecmd.ConfirmSafety(leaf, *final.Safety); !isConfirmationGateError(err) {
|
||||
fails = append(fails, row{canonical, cliPath, "", fmt.Sprintf(
|
||||
"framework leaf: ConfirmSafety(ContractFinal.Safety) = %v, want confirmation gate", err)})
|
||||
continue
|
||||
}
|
||||
// Execute without --yes must not succeed (confirm-first or validate-then-confirm).
|
||||
if err := probeConfirmationGate(leaf); err == nil {
|
||||
fails = append(fails, row{canonical, cliPath, "",
|
||||
"framework leaf: Execute without --yes succeeded"})
|
||||
continue
|
||||
}
|
||||
gate = "framework_confirm_safety"
|
||||
}
|
||||
okRows = append(okRows, row{canonical, cliPath, gate, "ok"})
|
||||
}
|
||||
|
||||
if checked == 0 {
|
||||
t.Fatal("no user_required ContractFinal leaves found")
|
||||
}
|
||||
if len(fails) != 0 {
|
||||
for _, f := range fails {
|
||||
t.Errorf("%s (%s): %s", f.canonical, f.cliPath, f.detail)
|
||||
}
|
||||
t.Fatalf("Safety homology failed: %d/%d user_required leaves", len(fails), checked)
|
||||
}
|
||||
|
||||
byGate := map[string]int{}
|
||||
for _, r := range okRows {
|
||||
byGate[r.gate]++
|
||||
}
|
||||
t.Logf("user_required Safety homology OK: %d leaves gates=%v", checked, byGate)
|
||||
|
||||
// Sheet transitional dual gate: every Sheet --yes-only protect marker must
|
||||
// also carry DeclareLeafMetadata ConfirmSafety. A bare sheet_protect leaf
|
||||
// would mean the outer guard was dropped from the homology pair (or the
|
||||
// contract wrap never installed), silently widening authorization.
|
||||
if bare := byGate["sheet_protect"]; bare != 0 {
|
||||
t.Fatalf("transitional Sheet dual gate broken: %d leaf(ves) have sheet_protect without declare_leaf_confirm", bare)
|
||||
}
|
||||
if dual := byGate["declare_leaf+sheet_marker"]; dual == 0 {
|
||||
t.Fatal("expected declare_leaf+sheet_marker leaves for Sheet transitional dual confirmation gate")
|
||||
}
|
||||
|
||||
// Confirm/validate order is asserted by behavior, not by a claim of intent:
|
||||
// a "deferred" annotation can still confirm first at runtime. For every
|
||||
// user_required leaf that publishes a required parameter, an argument-less
|
||||
// invocation must report that missing parameter — reaching confirmation
|
||||
// instead is the RFC §5.1 inversion.
|
||||
//
|
||||
// Two forms declare guard-first ordering deliberately and are exempt: a Spec
|
||||
// ConfirmFirst field (the devapp write contract asserted by
|
||||
// TestEveryDevAppWriteCommandRequiresGuard) and the reviewed Sheet
|
||||
// confirmationGuards table, whose wrapper is --yes-only by design. Both are
|
||||
// declarations, not inferences, so the exemption cannot be reached by
|
||||
// accident.
|
||||
var orderFails []row
|
||||
for _, r := range okRows {
|
||||
leaf := boundByCanon[r.canonical].PrimaryCommand
|
||||
if leaf == nil || !requiresParameter(liveToolByCanon[r.canonical]) {
|
||||
continue
|
||||
}
|
||||
if corecmd.HasDeclaredConfirmFirst(leaf) || helpers.HasSheetMutationConfirmationGuard(leaf) {
|
||||
continue
|
||||
}
|
||||
if err := probeConfirmationGate(leaf); isConfirmationGateError(err) {
|
||||
orderFails = append(orderFails, row{r.canonical, r.cliPath, r.gate,
|
||||
fmt.Sprintf("confirms before reporting a missing required parameter: %v", err)})
|
||||
}
|
||||
}
|
||||
if len(orderFails) != 0 {
|
||||
for _, f := range orderFails {
|
||||
t.Errorf("%s (%s): %s", f.canonical, f.cliPath, f.detail)
|
||||
}
|
||||
t.Fatalf("confirm/validate order invariant failed: %d user_required leaf(ves)", len(orderFails))
|
||||
}
|
||||
}
|
||||
|
||||
// requiresParameter reports whether the delivered ToolSpec publishes at least
|
||||
// one required parameter, i.e. whether an argument-less invocation is expected
|
||||
// to fail validation before anything else.
|
||||
func requiresParameter(tool cli.ToolSpec) bool {
|
||||
for _, param := range tool.Parameters {
|
||||
if param.Required || param.CLIRequired {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func probeConfirmationGate(leaf *cobra.Command) error {
|
||||
root := leaf.Root()
|
||||
if root == nil {
|
||||
root = leaf
|
||||
}
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
|
||||
// Rebuild args from CommandPath relative to root.
|
||||
path := strings.TrimSpace(strings.TrimPrefix(leaf.CommandPath(), root.CommandPath()))
|
||||
fields := strings.Fields(path)
|
||||
root.SetArgs(fields)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
// isConfirmationGateError reports whether err is the confirmation gate itself.
|
||||
//
|
||||
// It classifies on the machine-readable reason rather than on prose: a Sheet
|
||||
// destructive leaf's missing-flag error embeds the example
|
||||
// "(获得用户确认后加 --yes)", so substring matching reported the confirmation gate
|
||||
// for a correct validation error. The decline path is the one case that carries
|
||||
// no reason, so it is matched by its exact message.
|
||||
func isConfirmationGateError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if errors.As(err, &typed) && strings.TrimSpace(typed.Reason) == "confirmation_required" {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(err.Error(), "用户取消了操作")
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// CatalogDegradedReason identifies why catalog discovery returned empty.
|
||||
type CatalogDegradedReason string
|
||||
|
||||
const (
|
||||
DegradedUnauthenticated CatalogDegradedReason = "unauthenticated"
|
||||
DegradedMarketUnreachable CatalogDegradedReason = "market_unreachable"
|
||||
DegradedRuntimeAllFailed CatalogDegradedReason = "runtime_all_failed"
|
||||
)
|
||||
|
||||
// CatalogDegraded is returned by EnvironmentLoader.Load when discovery
|
||||
// fails for a diagnosable reason. Callers that need graceful degradation
|
||||
// (e.g. the runtime runner) can check errors.As and fall back to an
|
||||
// empty catalog; callers like the schema command can surface the hint.
|
||||
type CatalogDegraded struct {
|
||||
Reason CatalogDegradedReason
|
||||
Hint string
|
||||
ServerCount int
|
||||
}
|
||||
|
||||
func (e *CatalogDegraded) Error() string { return string(e.Reason) + ": " + e.Hint }
|
||||
|
||||
func degradedHint(reason CatalogDegradedReason, serverCount int) string {
|
||||
embedded := edition.Get().IsEmbedded
|
||||
switch reason {
|
||||
case DegradedUnauthenticated:
|
||||
if embedded {
|
||||
return "未登录,请重新认证"
|
||||
}
|
||||
return "未登录,无法发现 MCP 服务。请先执行: dws auth login"
|
||||
case DegradedMarketUnreachable:
|
||||
if embedded {
|
||||
return "无法连接 MCP 市场,请检查网络"
|
||||
}
|
||||
return "无法连接 MCP 市场,请检查网络"
|
||||
case DegradedRuntimeAllFailed:
|
||||
if embedded {
|
||||
return fmt.Sprintf("已发现 %d 个服务但连接全部失败,请稍后重试", serverCount)
|
||||
}
|
||||
return fmt.Sprintf("已发现 %d 个服务但连接全部失败;静态端点模式下请检查 internal/syncdata 生成物或稍后重试", serverCount)
|
||||
default:
|
||||
return "MCP 服务发现失败"
|
||||
}
|
||||
}
|
||||
|
||||
func newCatalogDegraded(reason CatalogDegradedReason, serverCount int) *CatalogDegraded {
|
||||
return &CatalogDegraded{
|
||||
Reason: reason,
|
||||
Hint: degradedHint(reason, serverCount),
|
||||
ServerCount: serverCount,
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
CatalogFixtureEnv = "DWS_CATALOG_FIXTURE"
|
||||
CacheDirEnv = "DWS_CACHE_DIR"
|
||||
PluginColdTimeoutEnv = "DWS_PLUGIN_COLD_TIMEOUT"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Catalog types (formerly in internal/ir, now inlined as minimal stubs)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Catalog holds the discovered MCP product surface.
|
||||
type Catalog struct {
|
||||
Products []CanonicalProduct `json:"products"`
|
||||
}
|
||||
|
||||
// FindProduct returns the product with the given ID.
|
||||
func (c Catalog) FindProduct(id string) (CanonicalProduct, bool) {
|
||||
for _, product := range c.Products {
|
||||
if product.ID == id {
|
||||
return product, true
|
||||
}
|
||||
}
|
||||
return CanonicalProduct{}, false
|
||||
}
|
||||
|
||||
// CanonicalProduct represents a single MCP product in the catalog.
|
||||
type CanonicalProduct struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ServerKey string `json:"server_key"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Tools []ToolDescriptor `json:"tools"`
|
||||
}
|
||||
|
||||
// FindTool returns the tool with the given RPC name.
|
||||
func (p CanonicalProduct) FindTool(name string) (ToolDescriptor, bool) {
|
||||
for _, tool := range p.Tools {
|
||||
if tool.RPCName == name {
|
||||
return tool, true
|
||||
}
|
||||
}
|
||||
return ToolDescriptor{}, false
|
||||
}
|
||||
|
||||
// ToolDescriptor represents a single tool in a canonical product.
|
||||
type ToolDescriptor struct {
|
||||
RPCName string `json:"rpc_name"`
|
||||
CanonicalPath string `json:"canonical_path"`
|
||||
}
|
||||
|
||||
// CLIFlagHint holds CLI flag alias/shorthand metadata for a tool parameter.
|
||||
type CLIFlagHint struct {
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
Alias string `json:"alias,omitempty"`
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// CatalogLoader interface
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// CatalogLoader loads the canonical catalog.
|
||||
type CatalogLoader interface {
|
||||
Load(context.Context) (Catalog, error)
|
||||
}
|
||||
|
||||
// StaticLoader returns a pre-built catalog.
|
||||
type StaticLoader struct {
|
||||
Catalog Catalog
|
||||
}
|
||||
|
||||
func (l StaticLoader) Load(_ context.Context) (Catalog, error) {
|
||||
return l.Catalog, nil
|
||||
}
|
||||
|
||||
// CatalogLoaderFrom creates a CatalogLoader that returns a
|
||||
// pre-loaded catalog and error.
|
||||
func CatalogLoaderFrom(catalog Catalog, err error) CatalogLoader {
|
||||
return &preloadedLoader{catalog: catalog, err: err}
|
||||
}
|
||||
|
||||
type preloadedLoader struct {
|
||||
catalog Catalog
|
||||
err error
|
||||
}
|
||||
|
||||
func (l *preloadedLoader) Load(_ context.Context) (Catalog, error) {
|
||||
return l.catalog, l.err
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// EnvironmentLoader (static endpoint mode — no longer does live discovery)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// EnvironmentLoader provides catalog loading. In the post-discovery
|
||||
// architecture this always returns an empty catalog; endpoint resolution
|
||||
// is handled by the direct runtime path.
|
||||
type EnvironmentLoader struct {
|
||||
LookupEnv func(string) (string, bool)
|
||||
CatalogBaseURLOverride string
|
||||
AuthTokenFunc func(context.Context) string
|
||||
LoggerFunc func() *slog.Logger
|
||||
}
|
||||
|
||||
// NewEnvironmentLoader creates an EnvironmentLoader with default settings.
|
||||
func NewEnvironmentLoader() EnvironmentLoader {
|
||||
return EnvironmentLoader{}
|
||||
}
|
||||
|
||||
// Load returns an empty catalog. All endpoint resolution now uses the
|
||||
// direct runtime path (dynamic server registry).
|
||||
func (l EnvironmentLoader) Load(_ context.Context) (Catalog, error) {
|
||||
return Catalog{}, nil
|
||||
}
|
||||
|
||||
// Ensure unused imports are consumed.
|
||||
var _ = newCatalogDegraded
|
||||
@@ -159,7 +159,7 @@ type realFlag struct {
|
||||
// morphed name to the real flags that share that morph.
|
||||
func realFlagsByMorph(leaf *cobra.Command) map[string][]realFlag {
|
||||
byMorph := make(map[string][]realFlag)
|
||||
visitManualAgentCommandFlags(leaf, func(flag *pflag.Flag) {
|
||||
visitAgentExampleCommandFlags(leaf, func(flag *pflag.Flag) {
|
||||
if flag == nil || flag.Name == "help" {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -37,9 +38,7 @@ func conceptFixture() []Concept {
|
||||
|
||||
func useParamConceptLoader(t *testing.T, concepts ParamConcepts, err error) {
|
||||
t.Helper()
|
||||
previous := loadReviewedParamConcepts
|
||||
loadReviewedParamConcepts = func() (ParamConcepts, error) { return concepts, err }
|
||||
t.Cleanup(func() { loadReviewedParamConcepts = previous })
|
||||
testseam.Swap(t, &loadReviewedParamConcepts, func() (ParamConcepts, error) { return concepts, err })
|
||||
}
|
||||
|
||||
func TestReduceParamAliasesLoadsAndValidatesSourceTree(t *testing.T) {
|
||||
|
||||
@@ -21,7 +21,8 @@ const paramConceptsSchemaRef = "./param_concepts.schema.json"
|
||||
// the sole source of equivalent flag spellings ("concepts") plus per-command
|
||||
// overrides. Build-time generators reduce these concepts against each command's
|
||||
// real Cobra flags; generated alias tables are downstream views and must never
|
||||
// be read back here.
|
||||
// be read back here. It is a reviewed input peer of the collected command
|
||||
// identity (argv synonyms vs identity) — keep the authorities separate.
|
||||
|
||||
//go:embed param_concepts.json
|
||||
var embeddedParamConceptsJSON []byte
|
||||
@@ -144,7 +145,7 @@ var (
|
||||
embeddedParamConceptsOnce sync.Once
|
||||
embeddedParamConceptsData ParamConcepts
|
||||
embeddedParamConceptsErr error
|
||||
loadReviewedParamConcepts = loadEmbeddedParamConcepts
|
||||
loadReviewedParamConcepts = loadParamConceptsFromEmbed
|
||||
)
|
||||
|
||||
// LoadParamConcepts decodes and validates the embedded reviewed concept
|
||||
@@ -153,7 +154,7 @@ func LoadParamConcepts() (ParamConcepts, error) {
|
||||
return loadReviewedParamConcepts()
|
||||
}
|
||||
|
||||
func loadEmbeddedParamConcepts() (ParamConcepts, error) {
|
||||
func loadParamConceptsFromEmbed() (ParamConcepts, error) {
|
||||
embeddedParamConceptsOnce.Do(func() {
|
||||
embeddedParamConceptsData, embeddedParamConceptsErr = decodeParamConcepts(embeddedParamConceptsJSON)
|
||||
})
|
||||
|
||||
+279
-738
File diff suppressed because it is too large
Load Diff
@@ -10,69 +10,42 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeSchemaLoaderAndAnnotationEdges(t *testing.T) {
|
||||
originalJSON := embeddedMCPMetadataJSON
|
||||
t.Cleanup(func() { embeddedMCPMetadataJSON = originalJSON })
|
||||
embeddedMCPMetadataJSON = []byte("{")
|
||||
if got := loadEmbeddedMCPMetadata(); got.Tools == nil || len(got.Tools) != 0 {
|
||||
t.Fatalf("invalid embedded metadata = %#v", got)
|
||||
}
|
||||
embeddedMCPMetadataJSON = []byte(`{"version":1}`)
|
||||
if got := loadEmbeddedMCPMetadata(); got.Tools == nil {
|
||||
t.Fatal("nil tools map was not normalized")
|
||||
if got := emptyPinnedMCPMetadata(); got.Tools == nil || len(got.Tools) != 0 {
|
||||
t.Fatalf("empty pinned metadata = %#v", got)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "run", Short: "short", Long: "long"}
|
||||
AnnotateRuntimeToolMetadata(cmd, " title ", " description ", " source ")
|
||||
if cmd.Annotations[runtimeSchemaTitleAnnotation] != "title" {
|
||||
t.Fatalf("annotations = %#v", cmd.Annotations)
|
||||
}
|
||||
AnnotateRuntimePositionals(cmd, RuntimeSchemaPositional{Name: " ", Index: -1})
|
||||
AnnotateRuntimePositionals(cmd, contract.RuntimeSchemaPositional{Name: " ", Index: -1})
|
||||
if _, ok := cmd.Annotations[runtimeSchemaArgsAnnotation]; ok {
|
||||
t.Fatal("invalid positional should not be annotated")
|
||||
}
|
||||
if runtimeCommandTitle(nil) != "" || runtimeCommandDescription(nil) != "" {
|
||||
t.Fatal("nil command text should be empty")
|
||||
}
|
||||
cmd.Annotations[runtimeSchemaTitleAnnotation] = " annotated title "
|
||||
cmd.Annotations[runtimeSchemaDescAnnotation] = " annotated description "
|
||||
if runtimeCommandTitle(cmd) != "annotated title" || runtimeCommandDescription(cmd) != "annotated description" {
|
||||
t.Fatalf("annotated text = %q / %q", runtimeCommandTitle(cmd), runtimeCommandDescription(cmd))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCollectRuntimeSchemaEntriesErrorsAndOrdering(t *testing.T) {
|
||||
originalValidate := validateReviewedParameterBindings
|
||||
originalRegistry := loadReviewedCommandRegistry
|
||||
originalManual := loadReviewedManualSchemaHints
|
||||
t.Cleanup(func() {
|
||||
validateReviewedParameterBindings = originalValidate
|
||||
loadReviewedCommandRegistry = originalRegistry
|
||||
loadReviewedManualSchemaHints = originalManual
|
||||
})
|
||||
|
||||
validateReviewedParameterBindings = func() error { return errors.New("bindings failed") }
|
||||
testseam.Swap(t, &bindValidateParameterBindings, func() error { return errors.New("bindings failed") })
|
||||
if _, err := collectRuntimeSchemaEntries(&cobra.Command{Use: "dws"}); err == nil || !strings.Contains(err.Error(), "bindings failed") {
|
||||
t.Fatalf("validation error = %v", err)
|
||||
}
|
||||
|
||||
validateReviewedParameterBindings = func() error { return nil }
|
||||
loadReviewedCommandRegistry = func() (CommandRegistry, error) {
|
||||
return CommandRegistry{Commands: []CommandSpec{{
|
||||
CanonicalPath: "sample.run", PrimaryCLIPath: "sample run",
|
||||
Visibility: SchemaVisibilityPublic, Source: "reviewed_registry", ReviewReason: "test binding failure",
|
||||
}}}, nil
|
||||
}
|
||||
loadReviewedManualSchemaHints = func() (ManualSchemaHintSnapshot, error) {
|
||||
return ManualSchemaHintSnapshot{Schema: manualSchemaHintSchemaRef, Version: manualSchemaHintVersion}, nil
|
||||
}
|
||||
testseam.Swap(t, &bindValidateParameterBindings, func() error { return nil })
|
||||
prevGroups := reviewedRuntimeSchemaExclusionGroups
|
||||
t.Cleanup(func() { reviewedRuntimeSchemaExclusionGroups = prevGroups })
|
||||
reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{{
|
||||
ID: "", Reason: "x", Reviewed: true, Commands: []string{"x"},
|
||||
}}
|
||||
if _, err := collectRuntimeSchemaEntries(&cobra.Command{Use: "dws"}); err == nil {
|
||||
t.Fatal("missing Cobra path should fail binding")
|
||||
t.Fatal("invalid reviewed exclusions should fail identity collection")
|
||||
}
|
||||
reviewedRuntimeSchemaExclusionGroups = prevGroups
|
||||
|
||||
if _, err := collectRuntimeSchemaEntriesFromBound(BoundCommandRegistry{Commands: []BoundCommandSpec{{
|
||||
CommandSpec: CommandSpec{CanonicalPath: "invalid", Visibility: SchemaVisibilityPublic},
|
||||
@@ -90,20 +63,39 @@ func TestCrossPlatformCoverageCollectRuntimeSchemaEntriesErrorsAndOrdering(t *te
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeSchemaMetadataLookupEdges(t *testing.T) {
|
||||
originalHints := defaultSchemaHintRegistry
|
||||
t.Cleanup(func() { defaultSchemaHintRegistry = originalHints })
|
||||
defaultSchemaHintRegistry = newSchemaHintRegistry()
|
||||
defaultSchemaHintRegistry.RegisterProduct("source", map[string]ToolSchemaHint{
|
||||
"run": {Title: "source title"},
|
||||
})
|
||||
hint := runtimeSchemaHintForEntry(runtimeSchemaEntry{ProductID: "target", SourceProductID: "source", ToolName: "run"})
|
||||
if hint.Title != "source title" {
|
||||
t.Fatalf("source product hint = %#v", hint)
|
||||
}
|
||||
if _, ok := embeddedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, embeddedAgentMetadata{}, embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}); ok {
|
||||
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}); ok {
|
||||
t.Fatal("empty lookup unexpectedly matched")
|
||||
}
|
||||
|
||||
leaf := &cobra.Command{Use: "reply"}
|
||||
contractfinal.RegisterRuntimeContractFinal(leaf, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "sample", Name: "run", CanonicalPath: "sample.run",
|
||||
CLIPath: "sample run", PrimaryCLIPath: "sample run",
|
||||
},
|
||||
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeMCP,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(leaf) })
|
||||
mcp := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
|
||||
"chat.send_personal_message": {
|
||||
Parameters: map[string]embeddedMCPParamMeta{
|
||||
"clawType": {Type: "string"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
got, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{Command: leaf, ProductID: "chat", ToolName: "reply_personal_message"}, agentMetadata{}, mcp)
|
||||
if !ok || got.Parameters["clawType"].Type != "string" {
|
||||
t.Fatalf("ContractFinal Interface.Ref MCP remap = %#v ok=%v", got, ok)
|
||||
}
|
||||
if got.InterfaceRef == nil || got.InterfaceRef.RPCName != "send_personal_message" {
|
||||
t.Fatalf("InterfaceRef = %#v", got.InterfaceRef)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
value any
|
||||
want int
|
||||
@@ -118,28 +110,6 @@ func TestCrossPlatformCoverageRuntimeSchemaMetadataLookupEdges(t *testing.T) {
|
||||
t.Fatalf("tool count = %d, want %d", got, test.want)
|
||||
}
|
||||
}
|
||||
|
||||
originalResolver := resolveRuntimeSchemaField
|
||||
t.Cleanup(func() { resolveRuntimeSchemaField = originalResolver })
|
||||
for _, field := range []string{"title", "description"} {
|
||||
resolveRuntimeSchemaField = func(got string, candidates ...runtimeSchemaFieldCandidate) (runtimeSchemaFieldCandidate, error) {
|
||||
if got == field {
|
||||
return runtimeSchemaFieldCandidate{}, errors.New("forced " + field)
|
||||
}
|
||||
return resolveRuntimeSchemaCandidate(got, candidates...)
|
||||
}
|
||||
_, _, _, _, err := runtimeToolTextMetadataFromMetadata(runtimeSchemaEntry{Title: "title", Description: "description", MetadataSource: "native"}, runtimeSchemaMetadataSources{})
|
||||
if err == nil || !strings.Contains(err.Error(), field) {
|
||||
t.Fatalf("%s error = %v", field, err)
|
||||
}
|
||||
}
|
||||
resolveRuntimeSchemaField = func(field string, candidates ...runtimeSchemaFieldCandidate) (runtimeSchemaFieldCandidate, error) {
|
||||
return runtimeSchemaStringCandidate("selected", "mcp_metadata"), nil
|
||||
}
|
||||
_, _, source, _, err := runtimeToolTextMetadataFromMetadata(runtimeSchemaEntry{}, runtimeSchemaMetadataSources{})
|
||||
if err != nil || source != "embedded-mcp-metadata" {
|
||||
t.Fatalf("MCP metadata source = %q, err = %v", source, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeSchemaCandidateAndProvenanceEdges(t *testing.T) {
|
||||
@@ -149,7 +119,7 @@ func TestCrossPlatformCoverageRuntimeSchemaCandidateAndProvenanceEdges(t *testin
|
||||
if err != nil || winner.Precedence != "a" {
|
||||
t.Fatalf("precedence tie ordering winner = %#v, err = %v", winner, err)
|
||||
}
|
||||
if got := runtimeSchemaFieldProvenance(runtimeSchemaFieldCandidate{}); !reflect.DeepEqual(got, FieldProvenance{}) {
|
||||
if got := runtimeSchemaFieldProvenance(runtimeSchemaFieldCandidate{}); !reflect.DeepEqual(got, contract.FieldProvenance{}) {
|
||||
t.Fatalf("absent provenance = %#v", got)
|
||||
}
|
||||
bad := runtimeSchemaCandidate(func() {}, true, "custom")
|
||||
@@ -185,81 +155,61 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
cmd.Flags().String("value", "", "value")
|
||||
flag := cmd.Flags().Lookup("value")
|
||||
|
||||
originalBindingData := schemaParameterBindingData
|
||||
originalResolver := resolveRuntimeSchemaField
|
||||
originalPayloadSpecs := runtimeCommandParameterSpecsForPayload
|
||||
t.Cleanup(func() {
|
||||
schemaParameterBindingData = originalBindingData
|
||||
resolveRuntimeSchemaField = originalResolver
|
||||
runtimeCommandParameterSpecsForPayload = originalPayloadSpecs
|
||||
})
|
||||
|
||||
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", nil, nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
t.Fatalf("nil command specs = %#v, err = %v", specs, err)
|
||||
}
|
||||
schemaParameterBindingData = func() (schemaParameterBindingSnapshot, error) {
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{}, errors.New("load failed")
|
||||
}
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
|
||||
})
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
|
||||
t.Fatalf("binding load error = %v", err)
|
||||
}
|
||||
schemaParameterBindingData = func() (schemaParameterBindingSnapshot, error) {
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{Bindings: map[string]map[string]string{}, MappingExclusions: map[string]string{}}, nil
|
||||
}
|
||||
})
|
||||
if _, _, err := runtimeSchemaParameterMappingCandidates(schemaParameterBindingSnapshot{
|
||||
Bindings: map[string]map[string]string{},
|
||||
MappingExclusions: map[string]string{"sample.run --value": " "},
|
||||
}, "sample.run", "value"); err == nil {
|
||||
t.Fatal("empty mapping exclusion reason should fail")
|
||||
}
|
||||
schemaParameterBindingData = func() (schemaParameterBindingSnapshot, error) {
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{
|
||||
Bindings: map[string]map[string]string{},
|
||||
MappingExclusions: map[string]string{"sample.run --value": " "},
|
||||
}, nil
|
||||
}
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
|
||||
})
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
|
||||
t.Fatalf("mapping exclusion error = %v", err)
|
||||
}
|
||||
schemaParameterBindingData = func() (schemaParameterBindingSnapshot, error) {
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{Bindings: map[string]map[string]string{}, MappingExclusions: map[string]string{}}, nil
|
||||
}
|
||||
|
||||
cmd.Annotations = map[string]string{
|
||||
runtimeManualSchemaParameterKey(runtimeSchemaManualParameterAnnotation, "value"): "{",
|
||||
}
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "reviewed manual") {
|
||||
t.Fatalf("manual hint error = %v", err)
|
||||
}
|
||||
cmd.Annotations = nil
|
||||
})
|
||||
|
||||
realResolver := resolveRuntimeSchemaField
|
||||
for _, target := range []string{"property", "interface_type", "description", "required", "required_when", "format", "enum", "example"} {
|
||||
resolveRuntimeSchemaField = func(field string, candidates ...runtimeSchemaFieldCandidate) (runtimeSchemaFieldCandidate, error) {
|
||||
testseam.Swap(t, &resolveRuntimeSchemaField, func(field string, candidates ...runtimeSchemaFieldCandidate) (runtimeSchemaFieldCandidate, error) {
|
||||
if field == target {
|
||||
return runtimeSchemaFieldCandidate{}, errors.New("forced " + target)
|
||||
}
|
||||
return resolveRuntimeSchemaCandidate(field, candidates...)
|
||||
}
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
|
||||
})
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
|
||||
t.Fatalf("%s resolution error = %v", target, err)
|
||||
}
|
||||
}
|
||||
resolveRuntimeSchemaField = originalResolver
|
||||
resolveRuntimeSchemaField = realResolver
|
||||
|
||||
badHint := map[string]ParameterSchemaHint{"value": {FlagName: "other"}}
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", badHint, nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "does not identify") {
|
||||
t.Fatalf("hint flag mismatch error = %v", err)
|
||||
}
|
||||
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", nil, nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
t.Fatalf("empty specs = %#v, err = %v", specs, err)
|
||||
}
|
||||
if payload, err := runtimeCommandParameters(nil, "", nil, nil, RuntimeSchemaConstraints{}); err != nil || payload != nil {
|
||||
if payload, err := runtimeCommandParameters(nil, "", nil, RuntimeSchemaConstraints{}); err != nil || payload != nil {
|
||||
t.Fatalf("empty payload = %#v, err = %v", payload, err)
|
||||
}
|
||||
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]ParameterSchemaHint, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return []ParameterSpec{{Name: "bad", Example: json.RawMessage("{")}}, nil
|
||||
}
|
||||
if _, err := runtimeCommandParameters(cmd, "sample.run", nil, nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
|
||||
})
|
||||
if _, err := runtimeCommandParameters(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
|
||||
t.Fatalf("payload serialization error = %v", err)
|
||||
}
|
||||
|
||||
@@ -267,6 +217,34 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
if required, present := runtimeFlagRequiredState(flag); !required || !present {
|
||||
t.Fatalf("required annotation = %v/%v", required, present)
|
||||
}
|
||||
|
||||
// Fixture MCP-shaped maps still participate when explicitly injected.
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{
|
||||
Bindings: map[string]map[string]string{"sample.run": {"value": "clawType"}},
|
||||
}, nil
|
||||
})
|
||||
requiredTrue := true
|
||||
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", map[string]embeddedMCPParamMeta{
|
||||
"clawType": {
|
||||
Type: "string",
|
||||
Description: "fixture description",
|
||||
Required: &requiredTrue,
|
||||
Default: "fixture-default",
|
||||
},
|
||||
}, RuntimeSchemaConstraints{})
|
||||
if err != nil {
|
||||
t.Fatalf("fixture pinned parameter specs error = %v", err)
|
||||
}
|
||||
if len(specs) != 1 || specs[0].Property != "clawType" || specs[0].InterfaceDescription != "fixture description" {
|
||||
t.Fatalf("fixture pinned parameter specs = %#v", specs)
|
||||
}
|
||||
if len(specs[0].InterfaceDefault) == 0 {
|
||||
t.Fatalf("fixture interface_default missing: %#v", specs[0])
|
||||
}
|
||||
if prov := specs[0].FieldProvenance["required"]; prov.Source == "" {
|
||||
t.Fatalf("fixture required provenance missing: %#v", specs[0].FieldProvenance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeSchemaPureHelperEdges(t *testing.T) {
|
||||
@@ -289,11 +267,13 @@ func TestCrossPlatformCoverageRuntimeSchemaPureHelperEdges(t *testing.T) {
|
||||
if got := runtimeCommandPositionals(cmd); len(got) != 2 || got[0].Name != "first" {
|
||||
t.Fatalf("sorted positionals = %#v", got)
|
||||
}
|
||||
groups := normalizeRuntimeSchemaGroups([][]string{{" "}, {" ", "one", "one"}, {"one"}, {"one"}}, 1)
|
||||
groups := runtimeannotate.NormalizeConstraints(runtimeannotate.RuntimeSchemaConstraints{
|
||||
RequireOneOf: [][]string{{" "}, {" ", "one", "one"}, {"one"}, {"one"}},
|
||||
}).RequireOneOf
|
||||
if !reflect.DeepEqual(groups, [][]string{{"one"}}) {
|
||||
t.Fatalf("normalized groups = %#v", groups)
|
||||
}
|
||||
if meta, ok := lookupEmbeddedMCPParam(map[string]embeddedMCPParamMeta{"flag": {Type: "string"}}, "property", "flag"); !ok || meta.Type != "string" {
|
||||
if meta, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"flag": {Type: "string"}}, "property", "flag"); !ok || meta.Type != "string" {
|
||||
t.Fatalf("flag fallback metadata = %#v/%v", meta, ok)
|
||||
}
|
||||
if isGenericPayloadFlag(nil) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
)
|
||||
|
||||
// The declaration model and its runtime stores live under internal/corecmd/*
|
||||
// and every package imports them directly from there. This file only keeps
|
||||
// the cli.* convenience aliases so the delivery root's own code reads flat;
|
||||
// no separate cli shim packages exist.
|
||||
|
||||
type RuntimeSchemaConstraints = runtimeannotate.RuntimeSchemaConstraints
|
||||
|
||||
var (
|
||||
AttachRuntimeSchema = runtimeannotate.AttachRuntimeSchema
|
||||
AnnotateRuntimeFlag = runtimeannotate.AnnotateRuntimeFlag
|
||||
AnnotateRuntimeFlagProperty = runtimeannotate.AnnotateRuntimeFlagProperty
|
||||
AnnotateRuntimeRequiredFlags = runtimeannotate.AnnotateRuntimeRequiredFlags
|
||||
AnnotateRuntimeFlagRequiredValue = runtimeannotate.AnnotateRuntimeFlagRequiredValue
|
||||
AnnotateRuntimeFlagRequiredWhen = runtimeannotate.AnnotateRuntimeFlagRequiredWhen
|
||||
AnnotateRuntimeFlagFormat = runtimeannotate.AnnotateRuntimeFlagFormat
|
||||
AnnotateRuntimeFlagInterfaceType = runtimeannotate.AnnotateRuntimeFlagInterfaceType
|
||||
AnnotateRuntimeFlagEnum = runtimeannotate.AnnotateRuntimeFlagEnum
|
||||
AnnotateRuntimeFlagExample = runtimeannotate.AnnotateRuntimeFlagExample
|
||||
RuntimeContractRisk = runtimeannotate.RuntimeContractRisk
|
||||
RuntimeContractGate = runtimeannotate.RuntimeContractGate
|
||||
AnnotateRuntimeConstraints = runtimeannotate.AnnotateRuntimeConstraints
|
||||
AnnotateRuntimePositionals = runtimeannotate.AnnotateRuntimePositionals
|
||||
HasDeclaredOrAnnotatedConfirmation = contractfinal.HasDeclaredOrAnnotatedConfirmation
|
||||
RuntimeContractFinal = contractfinal.RuntimeContractFinal
|
||||
HasRuntimeContractFinal = contractfinal.HasRuntimeContractFinal
|
||||
ClearRuntimeContractFinalForTest = contractfinal.ClearRuntimeContractFinalForTest
|
||||
ApplyParamDecls = contractfinal.ApplyParamDecls
|
||||
)
|
||||
|
||||
func resolvedFieldProvenance(value any, source, sourceRef, precedence, resolution, reviewReason string) contract.FieldProvenance {
|
||||
return contract.ResolvedFieldProvenance(value, source, sourceRef, precedence, resolution, reviewReason)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user