diff --git a/docs/flag-help-schema-homology.md b/docs/flag-help-schema-homology.md index 47b65466..591d543d 100644 --- a/docs/flag-help-schema-homology.md +++ b/docs/flag-help-schema-homology.md @@ -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 编排 / 执行字段(不算声明) diff --git a/docs/rfc-command-framework-convergence.md b/docs/rfc-command-framework-convergence.md index fd66356d..cb12c50b 100644 --- a/docs/rfc-command-framework-convergence.md +++ b/docs/rfc-command-framework-convergence.md @@ -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`; diff --git a/internal/corecmd/corecmd.go b/internal/corecmd/corecmd.go index 5d0fbe59..04741dce 100644 --- a/internal/corecmd/corecmd.go +++ b/internal/corecmd/corecmd.go @@ -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 } diff --git a/internal/corecmd/input.go b/internal/corecmd/input.go new file mode 100644 index 00000000..a3bbbe18 --- /dev/null +++ b/internal/corecmd/input.go @@ -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 +} diff --git a/internal/corecmd/input_test.go b/internal/corecmd/input_test.go new file mode 100644 index 00000000..3edab9ab --- /dev/null +++ b/internal/corecmd/input_test.go @@ -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 }, + }) + }) + } +} diff --git a/internal/shortcut/adapter.go b/internal/shortcut/adapter.go index fd1f968d..7b870b83 100644 --- a/internal/shortcut/adapter.go +++ b/internal/shortcut/adapter.go @@ -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 diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go index 8e46ec3a..b3545f36 100644 --- a/internal/shortcut/types.go +++ b/internal/shortcut/types.go @@ -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