Merge pull request #1017 from typefield/feat/flag-input-file-stdin

feat(corecmd): support @file / stdin input sources on string flags
This commit is contained in:
github-actions[bot]
2026-08-15 12:50:22 +08:00
committed by GitHub
7 changed files with 595 additions and 1 deletions
+1
View File
@@ -92,6 +92,7 @@ command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷
| `Required` / `MarkRequired` | 非空校验 / cobra 硬必填 | 是(`required`) |
| `RequiredHint`, `Aliases`, `EnvVar` | 校验提示、隐藏别名、环境回退 | 否(执行细节;别名不上主 parameter 表) |
| `ArgDefault`, `Bind`, `OmitEmpty`, `Trim`, `Transform` | toolArgs 装配语义 | 否(载荷细节;`Bind` 可进 property 映射,但不另造 flag) |
| `Input` | 额外取值来源:`@path` 读文件 / `-` 读 stdin,在 required/enum/约束/`Validate` 之前原地解析 | 否(今日:能力由作者写进 `Usage` / `SchemaDescription` 文案,是已声明事实而非推断;不另造 flag。目标形态收敛为类型化投影字段,见 RFC §5.3) |
#### 1.2.2 编排 / 执行字段(不算声明)
+47 -1
View File
@@ -292,7 +292,7 @@ Definition(仅声明;不可编译)
下列字段**是**框架声明面(经 `corecmd.New` 生效并嵌入 `dws.schema.*`):
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面)
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面;`Input` 是取值来源声明,经 `corecmd.New` 生效但**不**嵌入 `dws.schema.*`,能力靠 `Usage` 文案声明,见 §5.3)
- `Constraints`
- **非空** `Risk`(空值 = 运行时当只读确认,且**不**嵌入 `dws.schema.risk`)
- `ConstParams`(载荷声明;不上用户 flag 表)
@@ -673,6 +673,52 @@ func (k Key[T]) Declare(opts ...FlagOption[T]) FlagSpec
- 构造时拒绝 `InputSourceInvalid`。
- 当前没有任何 Shortcut 或 Leaf 声明 `Input`,因此 M1 增加能力且零上线表面变化。让现有命令采用它属于 §9 下的用户可见变更。
`Input` 的框架能力今日已在 `corecmd` 落地(声明即执行的过渡形态,语义与上文目标一致),使用指南:
**今日声明形态**:`FlagSpec.Input []string`,源常量 `corecmd.InputFile`(`"file"`)/ `corecmd.InputStdin`(`"stdin"`)。`helpers.LeafFlag` 是 `corecmd.FlagSpec` 别名,直接可用;`shortcut.Flag.Input` 同形声明,经 `FromShortcut` 映射到 `FlagSpec`。
```go
// LeafSpec / helpers
Flags: []helpers.LeafFlag{
{
Name: "content",
Usage: "文档内容(支持 @文件路径 或 - 读 stdin)",
Bind: "content",
Input: []string{corecmd.InputFile, corecmd.InputStdin},
},
}
// shortcut
Flags: []shortcut.Flag{
{Name: "markdown", Desc: "Markdown 内容(支持 @文件路径 或 -)",
Input: []string{"file", "stdin"}},
}
```
**运行时语义**(`resolveInputFlags`,在 `runDeclaredPreflight` 内、required/enum/约束/Validate 之前执行,原地改写 cobra flag 值):
- `--flag @path`:文件内容替换取值;`--flag -`:stdin 内容替换取值。
- `--flag @@value`:转义为字面 `@value`,不做来源解析。
- 只解析显式 CLI token(主名或别名);EnvVar 回落与注册默认值透传不解析。
- 内容前置剥离 UTF-8 BOM;`Trim` 等既有语义照常作用于解析后的值。
- 读取失败、源不支持、`@` 后空路径都是类型化校验错误(退出码 3);同时声明两种源而文件读取失败时附 stdin 引导 hint。
**作者守则**:
- 声明即全部能力:required/enum/约束/Validate 校验的已是解析后的真实内容,`Execute`/`Invoke` 无需任何额外代码。
- `Usage`/`Desc` 必须写明支持 `@路径`/`-`;框架不自动改写 help 文案,今日也不向 Schema 投影(新增投影字段须先过 homology 评审,避免 catalog drift)。
- `user_required` 确认的写命令若声明 `InputStdin`:stdin 在校验阶段被消费,交互确认将 fail-closed 为 `confirmation_required`,此类调用必须显式 `--yes`(或 `--dry-run`)。
- **声明前先确认取值空间不会被前缀吃掉**:声明 `InputFile` 后,任何以 `@` 开头的合法值都会被当成文件路径(本产品尤其常见的是 at 提及类取值,如 `--at-user @zhangsan` 会报读取文件失败),用户只能改用 `@@` 转义;声明 `InputStdin` 后字面值 `-` 不可达(与 curl 等约定一致)。若该 flag 的正常取值可能命中这两种形态,就不要声明对应来源。
- 声明在构造期校验(fail-closed panic):仅限 `KindString`;源值必须是 `file`/`stdin` 且不重复。
**今日实现与目标形态的差异**(迁移到本节目标 `FlagSpec` 时收敛):
| 维度 | 今日 | 目标 |
|---|---|---|
| 源类型 | `[]string` 常量 | 类型化 `InputSource` |
| 路径边界 | 直接本地文件 IO | 复用 §5.5.2 本地文件 effect 边界 |
| Schema 投影 | 无(靠作者在 Usage 声明) | 声明即最终源,随 Catalog 透传 |
核心 FlagSpec 故意没有:
- `Bind`;
+15
View File
@@ -145,6 +145,15 @@ type FlagSpec struct {
// alike) and makes a whitespace-only value count as empty in required checks.
Trim bool
// Input declares extra input sources for a KindString flag beyond the
// literal command-line value: InputFile enables @path (value replaced by
// the file content), InputStdin enables - (value replaced by stdin).
// "@@value" always escapes to the literal "@value". Only explicit CLI
// tokens are resolved; EnvVar fallback and registration defaults pass
// through unchanged. Resolution runs before required/enum/constraint/
// Validate checks, so they see the payload content. Empty = flag value only.
Input []string
// Schema parameter final facts (embedded to dws.schema.*; assembly pass-through).
Enum []string // accepted values
Format string // machine-readable format (e.g. uri)
@@ -365,6 +374,7 @@ func New(spec Spec) *cobra.Command {
validateDispatchDecl(spec)
validateSafetySpec(spec)
validateContractDecl(spec)
validateInputSpecs(spec.Use, spec.Flags)
// Help prose inherits the declaration when not authored separately:
// Selection.Examples (already contract-validated against the real flags)
// double as the --help Example block, keeping one authored source.
@@ -476,6 +486,11 @@ func runDeclaredPreflight(cmd *cobra.Command, args []string, spec Spec) error {
return err
}
}
// Input resolution rewrites explicit @file / stdin values in place so the
// required/enum/constraint/Validate stages below check the payload content.
if err := resolveInputFlags(cmd, spec.Flags); err != nil {
return err
}
if err := ValidateRequired(cmd, spec.Flags); err != nil {
return err
}
+191
View File
@@ -0,0 +1,191 @@
// 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 corecmd
import (
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
// FlagSpec.Input source constants. They declare extra input sources for a
// KindString flag beyond the literal command-line value, mirroring the
// lark-cli Flag.Input capability so large payloads (markdown, JSON, CSV)
// never need shell quoting.
const (
// InputFile allows the flag value @path to be replaced by the file content.
InputFile = "file"
// InputStdin allows the flag value - to be replaced by the stdin content.
InputStdin = "stdin"
)
// utf8BOM is stripped from file/stdin content so a Windows-edited payload
// cannot corrupt the first CSV cell or break JSON parsing downstream.
const utf8BOM = "\ufeff"
// validateInputSpecs rejects malformed Input declarations at build time. Like
// the other declaration checks this panics: a bad Input spec is a programming
// error every test and startup path should trip immediately.
func validateInputSpecs(use string, flags []FlagSpec) {
for _, flag := range flags {
if len(flag.Input) == 0 {
continue
}
if flag.Kind != KindString {
panic(fmt.Sprintf(
"command %q flag %q: Input is only supported on KindString flags",
use, flag.Name))
}
seen := map[string]bool{}
for _, source := range flag.Input {
if source != InputFile && source != InputStdin {
panic(fmt.Sprintf(
"command %q flag %q: unknown Input source %q (allowed: %s, %s)",
use, flag.Name, source, InputFile, InputStdin))
}
if seen[source] {
panic(fmt.Sprintf(
"command %q flag %q: duplicate Input source %q",
use, flag.Name, source))
}
seen[source] = true
}
}
}
// inputSupports reports whether the flag declared the given input source.
func inputSupports(flag FlagSpec, source string) bool {
for _, s := range flag.Input {
if s == source {
return true
}
}
return false
}
// explicitInputFlagName picks the declared name that carries the user's
// explicit token, mirroring rawValue's order and usability judgement exactly:
// the main flag wins only when changed and usable (trim-judged only when
// Trim is set), then declared aliases in order. Diverging here could rewrite
// an alias that rawValue would shadow (or vice versa). EnvVar fallback and
// registration defaults are never input-resolved — @file only applies to what
// the user literally typed.
func explicitInputFlagName(cmd *cobra.Command, flag FlagSpec) string {
usable := func(v string) bool {
if flag.Trim {
v = strings.TrimSpace(v)
}
return v != ""
}
for _, name := range append([]string{flag.Name}, flag.Aliases...) {
if cmd.Flags().Changed(name) && usable(cmdutil.MustGetFlag(cmd, name)) {
return name
}
}
return ""
}
// resolveInputFlags rewrites the explicit values of Input-declaring flags
// before required/enum/constraint/Validate checks, so every downstream stage
// sees the real payload content. Semantics:
//
// - "-" reads stdin; a process has a single stdin, so a second Input flag
// using "-" in the same invocation is rejected.
// - "@path" reads the named file (leading/trailing whitespace trimmed).
// - "@@value" escapes to the literal "@value" and is not source-resolved.
//
// Resolution runs once per invocation inside runDeclaredPreflight. It
// rewrites the cobra flag value in place (Set keeps Changed=true), so
// fallback-chain readers (EffectiveValue/BuildArgs) observe the content.
//
// Interaction with confirmation: stdin is consumed here, before the Safety
// prompt of a user_required write would read it; such an interactive prompt
// then sees EOF and fails closed with confirmation_required. Write commands
// declaring InputStdin must be invoked with --yes (or --dry-run).
func resolveInputFlags(cmd *cobra.Command, flags []FlagSpec) error {
stdinConsumed := false
for _, flag := range flags {
if len(flag.Input) == 0 {
continue
}
name := explicitInputFlagName(cmd, flag)
if name == "" {
continue
}
raw := cmdutil.MustGetFlag(cmd, name)
// Trim flags judge usability on the trimmed value (rawValue), so the
// prefix check must agree or " @path" would slip through unresolved.
if flag.Trim {
raw = strings.TrimSpace(raw)
}
switch {
case raw == "-":
if !inputSupports(flag, InputStdin) {
return apperrors.NewValidation(
fmt.Sprintf("参数 --%s 不支持 stdin 输入(-)", flag.Name))
}
if stdinConsumed {
return apperrors.NewValidation(
fmt.Sprintf("参数 --%s:stdin(-)只能被一个参数使用", flag.Name),
apperrors.WithHint(fmt.Sprintf(
"一个进程只有一份 stdin,其余参数请内联传值或用 @文件路径(如 --%s @./payload.json)",
flag.Name)))
}
stdinConsumed = true
data, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return apperrors.NewValidation(
fmt.Sprintf("参数 --%s 读取 stdin 失败:%v", flag.Name, err))
}
// Setting a registered string flag cannot fail.
_ = cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM))
case strings.HasPrefix(raw, "@@"):
// Escape: strip the first @, keep the rest as a literal inline value.
_ = cmd.Flags().Set(name, raw[1:])
case strings.HasPrefix(raw, "@"):
if !inputSupports(flag, InputFile) {
return apperrors.NewValidation(
fmt.Sprintf("参数 --%s 不支持文件输入(@路径)", flag.Name))
}
path := strings.TrimSpace(raw[1:])
if path == "" {
return apperrors.NewValidation(
fmt.Sprintf("参数 --%s:@ 后的文件路径不能为空", flag.Name))
}
data, err := os.ReadFile(path)
if err != nil {
var opts []apperrors.Option
if inputSupports(flag, InputStdin) {
// Rejected @file paths are usually absolute (temp files).
// Steer toward stdin rather than copying the file around.
opts = append(opts, apperrors.WithHint(fmt.Sprintf(
"该参数也支持 stdin:把文件内容管道进命令并传 --%s -", flag.Name)))
}
return apperrors.NewValidation(
fmt.Sprintf("参数 --%s 读取文件 %q 失败:%v", flag.Name, path, err), opts...)
}
_ = cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM))
}
}
return nil
}
+334
View File
@@ -0,0 +1,334 @@
// 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 corecmd
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
// newInputCommand builds a Spec whose Invoke captures toolArgs, so tests can
// assert what the full pipeline (resolution → validation → BuildArgs) ships.
func newInputCommand(flags []FlagSpec, captured *map[string]any) *cobra.Command {
return New(Spec{
Use: "t",
Short: "t",
Flags: flags,
Invoke: func(c *Ctx, toolArgs map[string]any) error {
*captured = toolArgs
return nil
},
})
}
func runInputCommand(t *testing.T, cmd *cobra.Command, args ...string) error {
t.Helper()
cmd.SetArgs(args)
return cmd.Execute()
}
func writeInputFile(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "payload.txt")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
return path
}
func TestCrossPlatformCoverageResolveInputFlagsFile(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}},
}, &got)
path := writeInputFile(t, "# hello\nworld")
if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "# hello\nworld" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
func TestCrossPlatformCoverageResolveInputFlagsStdin(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}},
}, &got)
cmd.SetIn(strings.NewReader("piped payload"))
if err := runInputCommand(t, cmd, "--content", "-"); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "piped payload" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
// The @@ escape keeps a literal leading @ inline and must not be treated as a
// source reference even when Input is declared.
func TestCrossPlatformCoverageResolveInputFlagsEscapedAtStaysInline(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}},
}, &got)
if err := runInputCommand(t, cmd, "--content", "@@literal@x"); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "@literal@x" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
func TestCrossPlatformCoverageResolveInputFlagsBOMStripped(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}},
}, &got)
path := writeInputFile(t, "\ufeff{\"a\":1}")
if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "{\"a\":1}" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
// Required must be satisfied by the resolved payload, proving resolution runs
// before the required stage.
func TestCrossPlatformCoverageResolveInputFlagsSatisfiesRequired(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Required: true, Input: []string{InputFile}},
}, &got)
path := writeInputFile(t, "payload")
if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "payload" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
// Enum validation sees the resolved content, not the "@path" token.
func TestCrossPlatformCoverageResolveInputFlagsEnumValidatesResolvedContent(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "mode", Usage: "M", Bind: "mode", Enum: []string{"asc", "desc"}, Input: []string{InputFile}},
}, &got)
path := writeInputFile(t, "sideways")
err := runInputCommand(t, cmd, "--mode", "@"+path)
if err == nil || !strings.Contains(err.Error(), "不合法") {
t.Fatalf("expected enum rejection on resolved content, got %v", err)
}
}
// A value passed through a declared alias is resolved exactly like the main
// name (fallback-chain parity).
func TestCrossPlatformCoverageResolveInputFlagsAliasResolved(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}},
}, &got)
path := writeInputFile(t, "via alias")
if err := runInputCommand(t, cmd, "--body", "@"+path); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "via alias" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
// When the main flag shadows a changed alias (rawValue usability order), the
// shadowed alias must not be input-resolved: resolution targets exactly the
// name the fallback chain will read. The whitespace main value is usable for
// a non-Trim flag, and the alias path does not exist on purpose — a resolver
// that wrongly picked the alias would fail the read instead of shipping " ".
func TestCrossPlatformCoverageResolveInputFlagsShadowedAliasNotResolved(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}},
}, &got)
missing := filepath.Join(t.TempDir(), "missing.txt")
if err := runInputCommand(t, cmd, "--content", " ", "--body", "@"+missing); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != " " {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
func TestCrossPlatformCoverageResolveInputFlagsFileNotSupported(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}},
}, &got)
err := runInputCommand(t, cmd, "--content", "@/tmp/whatever.txt")
if err == nil || !strings.Contains(err.Error(), "不支持文件输入") {
t.Fatalf("expected file-input rejection, got %v", err)
}
}
func TestCrossPlatformCoverageResolveInputFlagsStdinNotSupported(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}},
}, &got)
cmd.SetIn(strings.NewReader("x"))
err := runInputCommand(t, cmd, "--content", "-")
if err == nil || !strings.Contains(err.Error(), "不支持 stdin") {
t.Fatalf("expected stdin rejection, got %v", err)
}
}
func TestCrossPlatformCoverageResolveInputFlagsSingleStdinConsumer(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "first", Usage: "F", Bind: "first", Input: []string{InputStdin}},
{Name: "second", Usage: "S", Bind: "second", Input: []string{InputStdin}},
}, &got)
cmd.SetIn(strings.NewReader("x"))
err := runInputCommand(t, cmd, "--first", "-", "--second", "-")
if err == nil || !strings.Contains(err.Error(), "只能被一个参数使用") {
t.Fatalf("expected single-stdin rejection, got %v", err)
}
}
// failingReader (corecmd_test.go) fails every read, making the stdin error
// branch reachable.
func TestCrossPlatformCoverageResolveInputFlagsStdinReadFailure(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}},
}, &got)
cmd.SetIn(failingReader{})
err := runInputCommand(t, cmd, "--content", "-")
if err == nil || !strings.Contains(err.Error(), "读取 stdin 失败") {
t.Fatalf("expected stdin read failure, got %v", err)
}
}
func TestCrossPlatformCoverageResolveInputFlagsFileNotFound(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}},
}, &got)
err := runInputCommand(t, cmd, "--content", "@"+filepath.Join(t.TempDir(), "missing.txt"))
if err == nil || !strings.Contains(err.Error(), "读取文件") {
t.Fatalf("expected read failure, got %v", err)
}
}
func TestCrossPlatformCoverageResolveInputFlagsEmptyPathAfterAt(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}},
}, &got)
err := runInputCommand(t, cmd, "--content", "@ ")
if err == nil || !strings.Contains(err.Error(), "文件路径不能为空") {
t.Fatalf("expected empty-path rejection, got %v", err)
}
}
// A flag without Input keeps its literal value even when it looks like a
// source reference — resolution is strictly opt-in per declaration.
func TestCrossPlatformCoverageResolveInputFlagsNoInputSpecPassthrough(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "token", Usage: "T", Bind: "token"},
}, &got)
if err := runInputCommand(t, cmd, "--token", "@not-a-file"); err != nil {
t.Fatalf("execute: %v", err)
}
if got["token"] != "@not-a-file" {
t.Fatalf("toolArgs[token] = %q", got["token"])
}
}
// Registration defaults and env fallback are never input-resolved: @file only
// applies to what the user literally typed on the command line.
func TestCrossPlatformCoverageResolveInputFlagsDefaultNotResolved(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Default: "@not-a-file", Input: []string{InputFile}},
}, &got)
if err := runInputCommand(t, cmd); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "@not-a-file" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
// Trim flags judge usability on the trimmed value (rawValue), so a leading
// whitespace before @ must still resolve instead of shipping as a literal.
func TestCrossPlatformCoverageResolveInputFlagsTrimmedLeadingSpace(t *testing.T) {
var got map[string]any
cmd := newInputCommand([]FlagSpec{
{Name: "content", Usage: "C", Bind: "content", Trim: true, Input: []string{InputFile}},
}, &got)
path := writeInputFile(t, "trimmed payload")
if err := runInputCommand(t, cmd, "--content", " @"+path); err != nil {
t.Fatalf("execute: %v", err)
}
if got["content"] != "trimmed payload" {
t.Fatalf("toolArgs[content] = %q", got["content"])
}
}
func TestCrossPlatformCoverageValidateInputSpecsPanics(t *testing.T) {
cases := []struct {
name string
flag FlagSpec
}{
{"non-string kind", FlagSpec{Name: "n", Kind: KindInt, Input: []string{InputFile}}},
{"unknown source", FlagSpec{Name: "s", Input: []string{"url"}}},
{"duplicate source", FlagSpec{Name: "s", Input: []string{InputFile, InputFile}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()
New(Spec{
Use: "t",
Short: "t",
Flags: []FlagSpec{tc.flag},
Invoke: func(c *Ctx, toolArgs map[string]any) error { return nil },
})
})
}
}
+1
View File
@@ -217,6 +217,7 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec {
RequiredError: fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc),
Enum: append([]string(nil), f.Enum...),
Aliases: append([]string(nil), f.Aliases...),
Input: append([]string(nil), f.Input...),
})
}
return out
+6
View File
@@ -107,6 +107,12 @@ type Flag struct {
// compatibility escape hatch for aliases that were historically public.
Aliases []string `json:"-"`
AliasesVisible bool `json:"-"`
// Input declares extra input sources for a string flag beyond the literal
// command-line value: "file" enables @path (value replaced by the file
// content), "stdin" enables - (value replaced by stdin). "@@value" escapes
// to the literal "@value". Resolution happens before Required/Enum/Validate
// checks. Empty = flag value only.
Input []string `json:"input,omitempty"`
}
// ConstraintKind is a machine-readable cross-parameter or custom validation