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:
@@ -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 编排 / 执行字段(不算声明)
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user