diff --git a/affordance/README.md b/affordance/README.md new file mode 100644 index 00000000..ba760874 --- /dev/null +++ b/affordance/README.md @@ -0,0 +1,49 @@ +# Affordance + +Per-command usage guidance for the CLI, authored as one markdown file per domain +(`.md`). It is surfaced in `lark-cli --help` and in the +`schema` output, and read directly at runtime (lazy, cached) — there is no build +step. Maintain these files alongside `skills/` and `shortcuts/`. + +## Format + +A small, fixed markdown subset; each file describes one domain: + + # optional `> skill: ` applies to every command below + ## the command as typed, minus `lark-cli ` + when to use this command + ### Avoid when when not to use it / which command to use instead + ### Prerequisites what you must have first (e.g. an id, and where it comes from) + ### Tips gotchas and constraints + ### Examples **description** lines, each followed by a fenced command + ### a custom section; flows through verbatim + +Reference another command with `[[command]]` — it renders as `command` in help. +Under `Avoid when` it means "use that one instead"; under `Prerequisites` +("… from [[command]]") it means "get the input there first". + +## Example + + ## messages get + Fetch the full content of a single message by id. + + ### Avoid when + - Reading several at once → use [[messages batch_get]] + + ### Prerequisites + - message_id from [[messages list]] + + ### Examples + + **Fetch one message** + ```bash + lark-cli mail user_mailbox.messages get --message-id "" + ``` + +## Notes + +- Write plain prose; the only convention is wrapping command references in `[[ ]]`. +- Keep it concise and high-signal — don't restate field/flag names, id types, or + anything the schema and flags already show; the agent infers the rest. +- Command-form headings resolve to method ids via the registry, so plural resource + names (`messages`) map to the singular method id (`message`) automatically. diff --git a/affordance/contact.md b/affordance/contact.md new file mode 100644 index 00000000..e0a71e70 --- /dev/null +++ b/affordance/contact.md @@ -0,0 +1,19 @@ +# contact +> skill: lark-contact + +## user_profiles batch_query +Bulk-fetch personal status and signature for user ids you already have. + +### Avoid when +- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]] + +### Tips +- Off by default — set include_personal_status / include_description to true under query_option +- ids in user_ids must match --user-id-type (default open_id) + +### Examples + +**Bulk-query status and signature** +```bash +lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}' +``` diff --git a/cmd/api/api.go b/cmd/api/api.go index 34b60ff8..20ab7bda 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -67,8 +67,21 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP cmd := &cobra.Command{ Use: "api ", - Short: "Generic Lark API requests", - Args: cobra.ExactArgs(2), + Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)", + Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path. + +Prefer the typed domain command when one exists — it validates parameters, +shows the Risk level, gates destructive calls behind --yes, and carries usage +guidance that this raw command does not. If a domain command covers your task +(browse with ` + "`lark-cli --help`" + `), use it instead of this. + +Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g. +newer/preview APIs), where you already have the HTTP path from the Lark docs. + +Examples: + lark-cli api GET /open-apis/calendar/v4/calendars + lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`, + Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { opts.Method = strings.ToUpper(args[0]) opts.Path = args[1] diff --git a/cmd/build.go b/cmd/build.go index a4716fb3..029c8e4d 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -170,6 +170,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B rootCmd.SetOut(cfg.streams.Out) rootCmd.SetErr(cfg.streams.ErrOut) + // Root-only usage template (curated Usage synopsis + skills footer); see + // rootUsageTemplate. + rootCmd.SetUsageTemplate(rootUsageTemplate) + installTipsHelpFunc(rootCmd) rootCmd.SilenceErrors = true // SilenceUsage as a static field (not only in PersistentPreRun) so it also @@ -205,6 +209,8 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B } shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f) + groupRootCommands(rootCmd) + installUnknownSubcommandGuard(rootCmd) if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode { diff --git a/cmd/root.go b/cmd/root.go index 74ba18d2..8e4e52cd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,9 +11,11 @@ import ( "sort" "strings" + "github.com/larksuite/cli/cmd/service" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/deprecation" @@ -28,43 +30,60 @@ import ( const rootLong = `lark-cli — Lark/Feishu CLI tool. -USAGE: - lark-cli [subcommand] [method] [options] - lark-cli api [--params ] [--data ] - lark-cli schema +AGENT QUICKSTART (driving this as an agent? start here): + Browse commands: lark-cli --help # +shortcuts (preferred) and raw API resources + Inspect a call: lark-cli schema .. # params, types, scopes, examples + Prefer a +shortcut over the raw API resource when one matches the task. + Risk: each command's --help shows read | write | high-risk-write; + high-risk-write needs --yes, only after the user confirms. + On any API call: --jq filters JSON output, --dry-run previews the request (runs nothing). -EXAMPLES: - # View upcoming events - lark-cli calendar +agenda +EXAMPLES (one per command style, in order of preference): + lark-cli calendar +agenda # +shortcut — a high-level task, prefer these + lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method + lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling + lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path` - # List calendar events - lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}' +// rootUsageTemplate is cobra's default usage template with two root-only +// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis +// (replacing cobra's generic "[flags] / [command]") and a human skills-setup +// footer. Subcommands render the stock template unchanged. The rest is verbatim +// cobra so the command groups and flags are untouched. +const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [command]{{end}}{{else}}Usage: + lark-cli [subcommand] [method] [flags] + lark-cli api [--params ] [--data ] + lark-cli schema {{end}}{{if gt (len .Aliases) 0}} - # Search users - lark-cli contact +search-user --query "John" +Aliases: + {{.NameAndAliases}}{{end}}{{if .HasExample}} - # Generic API call - lark-cli api GET /open-apis/calendar/v4/calendars +Examples: +{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} -AI AGENT SKILLS: - lark-cli pairs with AI agent skills (Claude Code, etc.) that - teach the agent Lark API patterns, best practices, and workflows. +Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} - Install all skills: - npx skills add larksuite/cli -g -y +{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} - Or pick specific domains: - npx skills add larksuite/cli -s lark-calendar -y - npx skills add larksuite/cli -s lark-im -y +Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} - Learn more: https://github.com/larksuite/cli#agent-skills +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} -COMMUNITY: - GitHub: https://github.com/larksuite/cli - Issues: https://github.com/larksuite/cli/issues - Docs: https://open.feishu.cn/document/ +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} -More help: lark-cli --help` +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}} + +Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}} +` // Execute runs the root command and returns the process exit code. // rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's @@ -529,6 +548,49 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin return available, deprecated } +// Root command help groups, so an agent sees content domains, agent tooling, and +// CLI management as distinct blocks instead of one flat alphabetical dump. +const ( + groupDomains = "lark-domains" + groupTooling = "agent-tooling" + groupManagement = "cli-management" +) + +// groupRootCommands classifies root's direct children into the help groups, +// called once after all commands are registered. Unclassified commands fall to +// cobra's "Additional Commands" section. +func groupRootCommands(root *cobra.Command) { + root.AddGroup( + &cobra.Group{ID: groupDomains, Title: "Lark domains:"}, + &cobra.Group{ID: groupTooling, Title: "Agent tooling:"}, + &cobra.Group{ID: groupManagement, Title: "CLI management:"}, + ) + tooling := map[string]bool{"api": true, "schema": true, "skills": true} + management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true} + for _, c := range root.Commands() { + if c.GroupID != "" { + continue + } + switch { + case tooling[c.Name()]: + c.GroupID = groupTooling + case management[c.Name()]: + c.GroupID = groupManagement + case isLarkDomain(c): + c.GroupID = groupDomains + } + } +} + +// isLarkDomain reports whether a root child is a Lark domain (service-sourced or +// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp. +func isLarkDomain(c *cobra.Command) bool { + if src, _ := cmdmeta.SourceOf(c); src == cmdmeta.SourceService { + return true + } + return cmdmeta.Domain(c) != "" +} + // flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It // converts cobra's flag-parse errors into a typed validation envelope: an // unknown flag gets a focused "did you mean" hint (so agents recover even when @@ -610,6 +672,17 @@ func installTipsHelpFunc(root *cobra.Command) { defer func() { f.Hidden = true }() } } + // Domain and method commands compose their agent guidance into Long lazily + // here (shortcuts attach after service registration); both skip the generic + // bottom-of-help append below. + if service.PrepareDomainHelp(cmd, embeddedSkillContent) { + defaultHelp(cmd, args) + return + } + if service.PrepareMethodHelp(cmd) { + defaultHelp(cmd, args) + return + } defaultHelp(cmd, args) out := cmd.OutOrStdout() if level, ok := cmdutil.GetRisk(cmd); ok { diff --git a/cmd/root_test.go b/cmd/root_test.go index b48dc64f..e7f3f5e7 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -76,11 +76,13 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) { } func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) { - if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") { - t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong) + // The human skills-install guidance now lives in the root usage-template + // footer (below the command list), not in the agent-facing Long. + if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") { + t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate) } - if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") { - t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong) + if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") { + t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate) } } diff --git a/cmd/service/affordance.go b/cmd/service/affordance.go index 53fd1a2f..6a9aa804 100644 --- a/cmd/service/affordance.go +++ b/cmd/service/affordance.go @@ -4,41 +4,211 @@ package service import ( + "encoding/json" "fmt" + "io/fs" "strings" + "github.com/larksuite/cli/internal/affordance" + "github.com/larksuite/cli/internal/cmdmeta" + "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/meta" + "github.com/spf13/cobra" ) -// methodLong composes a method command's long help in one place: the -// description, the affordance guidance block (when the method has one), the -// pointer to the full schema, and the params-only addendum (params whose flag -// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance -// sits near the top so an agent sees when-to-use and few-shot examples before -// the flag list. -func methodLong(description, affordance, schemaPath, paramsOnly string) string { +// PrepareDomainHelp appends navigational guidance (routing line, risk legend, +// skill pointer) to a top-level Lark domain's description, returning false for +// anything that is not such a domain. Built lazily at help time because +// shortcuts attach after service registration. skillFS (nil-safe) gates the +// skill pointer. +// +// A hand-authored Long is preserved as the base (e.g. event's "Use 'event +// consume '…"); service domains carry only a Short at this point, so +// we fall back to it. The pristine base is captured once into an annotation so +// re-rendering does not append the guidance twice. +func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool { + if cmd.Annotations[schemaPathAnnotation] != "" { + return false // a method command + } + // Direct child of root only — so Domain() reads this command's own tag, and + // nested resource groups are excluded. + if cmd.Parent() == nil || cmd.Parent().Parent() != nil { + return false + } + // A domain is service-sourced or shortcut-tagged; CLI tooling has neither. + if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceService && cmdmeta.Domain(cmd) == "" { + return false + } + if !cmd.HasAvailableSubCommands() { + return false + } + + hasShortcuts, hasResources := false, false + for _, c := range cmd.Commands() { + if c.Hidden || c.Name() == "help" || c.Name() == "completion" { + continue + } + if strings.HasPrefix(c.Name(), "+") { + hasShortcuts = true + } else { + hasResources = true + } + } + + var b strings.Builder + b.WriteString(domainHelpBase(cmd)) + if hasShortcuts && hasResources { // routing only matters when both styles exist + b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.") + } + b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.") + if skill := "lark-" + cmd.Name(); skillFS != nil { + if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil { + fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill) + } + } + cmd.Long = b.String() + return true +} + +// domainHelpBase returns the description to seed domain help with — the +// hand-authored Long when present, else the Short — captured once into an +// annotation so re-rendering reuses the pristine text instead of the +// already-augmented Long. +func domainHelpBase(cmd *cobra.Command) string { + if base, ok := cmd.Annotations[domainBaseAnnotation]; ok { + return base + } + base := cmd.Long + if base == "" { + base = cmd.Short + } + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[domainBaseAnnotation] = base + return base +} + +// methodLong is the build-time Long (description + schema pointer + +// params-only addendum). Agent guidance is added lazily by PrepareMethodHelp, +// so command construction never parses the overlay. +func methodLong(description, schemaPath, paramsOnly string) string { var b strings.Builder b.WriteString(description) - if affordance != "" { - b.WriteString("\n\n") - b.WriteString(affordance) - } - fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath) + fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath) b.WriteString(paramsOnly) return b.String() } -// renderAffordance renders a method's affordance as a help block — when to use, -// prerequisites, and (most importantly for agents) few-shot Examples — or "" when -// the method carries no affordance. It reads the single typed model -// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape. +// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long. +const ( + affordanceServiceAnnotation = "affordance-service" + affordanceMethodAnnotation = "affordance-method" + schemaPathAnnotation = "method-schema-path" + paramsOnlyAnnotation = "method-params-only" + domainBaseAnnotation = "affordance-domain-base" +) + +// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a +// few strings is the only build-time cost; the overlay stays untouched). +func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + if service != "" && methodID != "" { + cmd.Annotations[affordanceServiceAnnotation] = service + cmd.Annotations[affordanceMethodAnnotation] = methodID + } + cmd.Annotations[schemaPathAnnotation] = schemaPath + if paramsOnly != "" { + cmd.Annotations[paramsOnlyAnnotation] = paramsOnly + } +} + +// PrepareMethodHelp rebuilds a generated method command's Long with the agent +// guidance at the TOP (Risk, then the affordance block, then the schema +// pointer), returning false for non-method commands. The overlay is parsed +// here — only when help is rendered. +func PrepareMethodHelp(cmd *cobra.Command) bool { + ann := cmd.Annotations + if ann == nil { + return false + } + schemaPath, ok := ann[schemaPathAnnotation] + if !ok { + return false + } + + var b strings.Builder + b.WriteString(cmd.Short) + if level, ok := cmdutil.GetRisk(cmd); ok { + // --yes asserts the USER confirmed; the agent must not self-approve. + if level == cmdutil.RiskHighRiskWrite { + fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level) + } else { + fmt.Fprintf(&b, "\n\nRisk: %s", level) + } + } + + var skills []string + if raw, ok := affordanceRaw(cmd); ok { + if block := renderAffordance(meta.Method{Affordance: raw}); block != "" { + b.WriteString("\n\n") + b.WriteString(block) + } + if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok { + skills = a.Skills + } + } + + fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath) + b.WriteString(ann[paramsOnlyAnnotation]) + + if len(skills) > 0 { + b.WriteString("\n\nWorkflow skill (end-to-end usage):") + for _, s := range skills { + fmt.Fprintf(&b, "\n lark-cli skills read %s", s) + } + } + + cmd.Long = b.String() + return true +} + +// affordanceLookup is the overlay source; a package var so tests can inject. +var affordanceLookup = affordance.For + +// RenderAffordanceForCmd renders a method command's affordance block, or "" when +// it carries none. +func RenderAffordanceForCmd(cmd *cobra.Command) string { + raw, ok := affordanceRaw(cmd) + if !ok { + return "" + } + return renderAffordance(meta.Method{Affordance: raw}) +} + +func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) { + if cmd.Annotations == nil { + return nil, false + } + service := cmd.Annotations[affordanceServiceAnnotation] + methodID := cmd.Annotations[affordanceMethodAnnotation] + if service == "" || methodID == "" { + return nil, false + } + return affordanceLookup(service, methodID) +} + +// renderAffordance renders a method's affordance as a help block, or "" when it +// has none. Sections are joined with blank lines so they scan as distinct groups. func renderAffordance(m meta.Method) string { a, ok := m.ParsedAffordance() if !ok { return "" } - var b strings.Builder + var sections []string bullets := func(title string, items []string) { var nonEmpty []string for _, it := range items { @@ -49,15 +219,18 @@ func renderAffordance(m meta.Method) string { if len(nonEmpty) == 0 { return } - fmt.Fprintf(&b, "%s:\n", title) + var s strings.Builder + fmt.Fprintf(&s, "%s:\n", title) for _, it := range nonEmpty { - fmt.Fprintf(&b, " • %s\n", it) + fmt.Fprintf(&s, " • %s\n", it) } + sections = append(sections, strings.TrimRight(s.String(), "\n")) } bullets("When to use", a.UseWhen) - bullets("Avoid when", a.DoNotUseWhen) + bullets("Avoid when", a.AvoidWhen) bullets("Prerequisites", a.Prerequisites) + bullets("Tips", a.Tips) if len(a.Examples) > 0 { var lines []string for _, ex := range a.Examples { @@ -71,10 +244,13 @@ func renderAffordance(m meta.Method) string { } } if len(lines) > 0 { - fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n")) + sections = append(sections, "Examples:\n"+strings.Join(lines, "\n")) } } + for _, ext := range a.Extensions { + bullets(ext.Label, ext.Items) + } bullets("Related", a.Related) - return strings.TrimRight(b.String(), "\n") + return strings.Join(sections, "\n\n") } diff --git a/cmd/service/affordance_test.go b/cmd/service/affordance_test.go index e3111f62..e5a8a550 100644 --- a/cmd/service/affordance_test.go +++ b/cmd/service/affordance_test.go @@ -8,15 +8,18 @@ import ( "strings" "testing" + "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/meta" + "github.com/spf13/cobra" ) func TestRenderAffordance(t *testing.T) { raw := json.RawMessage(`{ "use_when": ["发送文本消息"], - "do_not_use_when": ["群已解散"], + "avoid_when": ["群已解散"], "prerequisites": ["已获取 chat_id"], + "tips": ["富文本用 msg_type=post"], "examples": [ {"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"}, {"command":"lark-cli im messages list"}, @@ -29,6 +32,7 @@ func TestRenderAffordance(t *testing.T) { "When to use:", "发送文本消息", "Avoid when:", "群已解散", "Prerequisites:", "已获取 chat_id", + "Tips:", "富文本用 msg_type=post", "Examples:", "发一条文本", "lark-cli im messages create --params '{...}'", "lark-cli im messages list", // example with no description -> bare command line "Related:", "im.messages.list", @@ -48,9 +52,12 @@ func TestRenderAffordance(t *testing.T) { } } -func TestServiceMethod_AffordanceInLong(t *testing.T) { +// Affordance is rendered lazily (at --help time) rather than baked into the +// command's Long, so building a command never carries the affordance block — +// even for a method whose metadata happens to declare one. +func TestServiceMethod_AffordanceNotInLong(t *testing.T) { withAff := map[string]interface{}{ - "path": "messages", "httpMethod": "POST", "description": "发送消息", + "id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息", "affordance": map[string]interface{}{ "examples": []interface{}{ map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."}, @@ -59,14 +66,120 @@ func TestServiceMethod_AffordanceInLong(t *testing.T) { } f, _, _, _ := cmdutil.TestFactory(t, testConfig) cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil) - if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") { - t.Errorf("affordance examples not in command Long:\n%s", cmd.Long) + if strings.Contains(cmd.Long, "Examples:") { + t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long) } - - // A method with no affordance adds no guidance block. - plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"} - cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil) - if strings.Contains(cmd2.Long, "Examples:") { - t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long) + // The lookup ref is recorded so the help path can resolve it later. + if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" { + t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations) + } +} + +// RenderAffordanceForCmd resolves a command's overlay through the (injectable) +// lookup and renders it; commands without a ref render nothing. +func TestRenderAffordanceForCmd(t *testing.T) { + orig := affordanceLookup + t.Cleanup(func() { affordanceLookup = orig }) + affordanceLookup = func(service, methodID string) (json.RawMessage, bool) { + if service != "im" || methodID != "messages.create" { + return nil, false + } + return json.RawMessage(`{"use_when":["发文本消息"],"tips":["富文本用 msg_type=post"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true + } + + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + withRef := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"} + cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withRef), "create", "messages", nil) + block := RenderAffordanceForCmd(cmd) + for _, want := range []string{"When to use:", "发文本消息", "Tips:", "富文本用 msg_type=post", "Examples:", "lark-cli im messages create ..."} { + if !strings.Contains(block, want) { + t.Errorf("RenderAffordanceForCmd missing %q in:\n%s", want, block) + } + } + + // No overlay for this method id -> empty block. + noRef := map[string]interface{}{"id": "x.list", "path": "x", "httpMethod": "GET", "description": "d"} + cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(noRef), "list", "x", nil) + if got := RenderAffordanceForCmd(cmd2); got != "" { + t.Errorf("method with no overlay should render nothing, got:\n%s", got) + } +} + +// PrepareMethodHelp composes the guidance into Long at the top: description, +// then the affordance block, then the full-schema pointer — so an agent reads +// when-to-use/examples before the flag list. +func TestPrepareMethodHelp(t *testing.T) { + orig := affordanceLookup + t.Cleanup(func() { affordanceLookup = orig }) + affordanceLookup = func(_, _ string) (json.RawMessage, bool) { + return json.RawMessage(`{"use_when":["发文本消息"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true + } + + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"} + cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil) + + if !PrepareMethodHelp(cmd) { + t.Fatal("PrepareMethodHelp returned false for a service-method command") + } + long := cmd.Long + // Description leads; affordance block sits above the schema pointer. + descAt := strings.Index(long, "发送消息") + useAt := strings.Index(long, "When to use:") + exAt := strings.Index(long, "Examples:") + schemaAt := strings.Index(long, "Full parameter schema:") + if descAt != 0 { + t.Errorf("description should lead Long, got:\n%s", long) + } + if !(descAt < useAt && useAt < exAt && exAt < schemaAt) { + t.Errorf("order should be description < affordance < schema pointer; got desc=%d use=%d ex=%d schema=%d\n%s", descAt, useAt, exAt, schemaAt, long) + } + + // A non-service command (no schema-path annotation) is left untouched. + if PrepareMethodHelp(&cobra.Command{Use: "plain"}) { + t.Error("PrepareMethodHelp should return false for a non-service command") + } +} + +// domainCmd wires a domain-tagged command with a subcommand under a root, the +// shape PrepareDomainHelp expects. +func domainCmd(short, long string) *cobra.Command { + root := &cobra.Command{Use: "root"} + dom := &cobra.Command{Use: "event", Short: short, Long: long} + cmdmeta.SetDomain(dom, "event") + dom.AddCommand(&cobra.Command{Use: "consume", Run: func(*cobra.Command, []string) {}}) + root.AddCommand(dom) + return dom +} + +func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) { + const long = "Unified event consumption system. Use 'event consume '." + dom := domainCmd("Consume and manage real-time events", long) + + if !PrepareDomainHelp(dom, nil) { + t.Fatal("PrepareDomainHelp returned false for a domain-tagged command") + } + if !strings.HasPrefix(dom.Long, long) { + t.Errorf("hand-authored Long must lead; got:\n%s", dom.Long) + } + if !strings.Contains(dom.Long, "Risk levels") { + t.Errorf("domain guidance should be appended; got:\n%s", dom.Long) + } + + // Re-rendering must not append the guidance a second time. + PrepareDomainHelp(dom, nil) + if n := strings.Count(dom.Long, "Risk levels"); n != 1 { + t.Errorf("guidance appended %d times across re-renders, want 1:\n%s", n, dom.Long) + } +} + +// A service domain carries only a Short at help time; it seeds the base. +func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) { + dom := domainCmd("Message and group chat management", "") + if !PrepareDomainHelp(dom, nil) { + t.Fatal("PrepareDomainHelp returned false for a domain-tagged command") + } + if !strings.HasPrefix(dom.Long, "Message and group chat management") { + t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long) } } diff --git a/cmd/service/flaggroups_test.go b/cmd/service/flaggroups_test.go index 59d741a4..391c7c99 100644 --- a/cmd/service/flaggroups_test.go +++ b/cmd/service/flaggroups_test.go @@ -60,8 +60,11 @@ func TestServiceFlagGroups_AgentContract(t *testing.T) { if i := idx("--chat-id"); i < iParams || i > iBody { t.Errorf("--chat-id not under API Parameters:\n%s", out) } - if !strings.Contains(out, "chat_id, required") { - t.Errorf("typed flag help format wrong:\n%s", out) + // The redundant ", required|optional." prefix is gone: required-ness is + // carried by the Required:/Optional: subheadings, and the snake-case --params + // key by the schema envelope — so it isn't echoed on every flag line. + if strings.Contains(out, "chat_id, required") || strings.Contains(out, "member_id_type, optional") { + t.Errorf("redundant , required/optional prefix should not appear:\n%s", out) } if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") { t.Errorf("expected compact enum value=meaning inline:\n%s", out) diff --git a/cmd/service/paramhelp.go b/cmd/service/paramhelp.go index e4a91521..cf144057 100644 --- a/cmd/service/paramhelp.go +++ b/cmd/service/paramhelp.go @@ -30,6 +30,11 @@ func fieldFacts(f meta.Field) []string { if d := sanitizeFieldDesc(f.Description); d != "" { facts = append(facts, d) } + if f.CanonicalType() == "boolean" { + // cobra shows no type word for bools and swallows a separate value as a + // positional, so spell out the presence-only contract. + facts = append(facts, "bool flag (presence = true; omit for false; takes no value)") + } if opts := f.EnumOptions(); len(opts) > 0 { facts = append(facts, "enum: "+formatEnumInline(opts)) } @@ -42,20 +47,15 @@ func fieldFacts(f meta.Field) []string { return facts } -// paramFlagUsage renders the typed param flag's help line: -// -// , required|optional[. ]... -// -// It leads with the canonical underscore param name (the key this flag -// overrides in --params) and required/optional, then joins the field's facts -// inline. +// paramFlagUsage renders the typed param flag's help line: the field's facts +// joined inline. Required/optional is not repeated here — the grouped help's +// Required:/Optional: subheadings already partition the flags — and the +// snake-case --params key is carried by the schema envelope (each param's +// property + "flag") and the params-only addendum, so it isn't echoed on every +// line either. Returns "" when the field has no facts (cobra then shows the bare +// flag with its type). func paramFlagUsage(f meta.Field) string { - req := "optional" - if f.Required { - req = "required" - } - parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...) - return strings.Join(parts, ". ") + "." + return strings.Join(fieldFacts(f), ". ") } // paramExample picks a concrete sample for a params-only field's --help snippet: @@ -103,8 +103,23 @@ func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r", // sanitizeFieldDesc is the field-description policy: one line per field, so // keep full sentences and cut only at note separators (meta_data appends // bullet notes after ;/;) — the later sentence often carries the key -// affordance, e.g. user_mailbox_id's `可以输入"me"`. -func sanitizeFieldDesc(s string) string { return inlineClause(s, ";;\n\r", 60) } +// affordance, e.g. user_mailbox_id's `可以输入"me"`. The trailing doc +// cross-reference is dropped first (see cutDocRef). +func sanitizeFieldDesc(s string) string { return inlineClause(cutDocRef(s), ";;\n\r", 60) } + +// docRefRe matches a "see the docs" breadcrumb (更多信息参见…/获取方式见…/详见…). +// On the compact flag line the markdown link's URL is stripped, so the +// breadcrumb is a dead pointer — drop it. Anchored on a leading clause separator +// so a subject that runs straight into the phrase isn't orphaned. +var docRefRe = regexp.MustCompile(`[。;;,,、]\s*(更多信息|获取方式|获取方法|详见|[请可]?参[见考阅])`) + +// cutDocRef truncates s at the first doc-reference breadcrumb. +func cutDocRef(s string) string { + if loc := docRefRe.FindStringIndex(s); loc != nil { + return s[:loc[0]] + } + return s +} // formatEnumInline renders allowed values for the help line: "v=meaning" when // the value carries a (sanitized, truncated) description — so opaque numeric diff --git a/cmd/service/service.go b/cmd/service/service.go index e6344dd7..3cb6ab5d 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "io" + "sort" "strings" "github.com/larksuite/cli/errs" @@ -64,15 +65,38 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc // resource-command chain — one level for a flat dotted resource like // "chat.members", deeper for genuinely nested resources. A service with no // methods keeps its bare command (svcCmd is created above regardless). - for _, ref := range apicatalog.ServiceMethods(svc, nil) { + refs := apicatalog.ServiceMethods(svc, nil) + + // Collect each resource's verbs up front so resourceShort can summarize a + // resource as its verb list from the first ensureChildCommand call. + verbs := map[string][]string{} + for _, ref := range refs { + key := strings.Join(ref.ResourcePath, ".") + verbs[key] = append(verbs[key], ref.Method.Name) + } + + for _, ref := range refs { resCmd := svcCmd + var path []string for _, seg := range ref.ResourcePath { - resCmd = ensureChildCommand(resCmd, seg, seg+" operations") + path = append(path, seg) + resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")])) } resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags())) } } +// resourceShort summarizes a resource as its sorted verb list, or the +// " operations" placeholder for an intermediate group with no methods. +func resourceShort(seg string, verbs []string) string { + if len(verbs) == 0 { + return seg + " operations" + } + sorted := append([]string(nil), verbs...) + sort.Strings(sorted) + return strings.Join(sorted, ", ") +} + // serviceShort is the service command's help summary: the localized description // from the registry, falling back to the metadata's own description. func serviceShort(svc meta.Service) string { @@ -177,7 +201,19 @@ type methodCommandSpec struct { // the API declares a body. acceptsBody bool declaresBody bool - affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none + paginates bool // method accepts a page_token param (so --page-all is meaningful) + serviceName string // owning service name (e.g. "approval"), for the lazy affordance lookup +} + +// methodPaginates reports whether a method takes a page_token param, the signal +// that makes the --page-all/--page-limit/--page-delay flags meaningful. +func methodPaginates(m meta.Method) bool { + for _, f := range m.Params() { + if f.Name == "page_token" { + return true + } + } + return false } func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec { @@ -186,6 +222,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec { method: m, schemaPath: ref.SchemaPath(), servicePath: ref.Service.ServicePath, + serviceName: ref.Service.Name, risk: m.Risk, restricts: m.RestrictsIdentity(), identities: m.Identities(), @@ -193,7 +230,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec { fileFields: detectFileFields(m), acceptsBody: methodTakesBody(m.HTTPMethod), declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0, - affordance: renderAffordance(m), + paginates: methodPaginates(m), } } @@ -254,6 +291,14 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages") cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)") cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages") + // Keep the pagination flags registered (a harmless no-op if passed) but hide + // them from help on non-paginating commands, so help doesn't imply a + // get/write can paginate. + if !spec.paginates { + for _, name := range []string{"page-all", "page-limit", "page-delay"} { + _ = cmd.Flags().MarkHidden(name) + } + } cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv") cmd.Flags().Bool("json", false, "shorthand for --format json") cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output") @@ -271,10 +316,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm // Registered last so the collision guard sees the standard flags above. opts.binder = newParamFlagBinder(cmd, spec.params, reserved) - // Single composition point for Long: description, affordance, schema - // pointer, and the binder's params-only addendum (params whose flag name is - // taken, reachable via --params only). - cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp()) + // Build-time Long; the agent guidance is added lazily by PrepareMethodHelp + // (setMethodHelpData records the coordinates it needs). + paramsOnly := opts.binder.paramsOnlyHelp() + cmd.Long = methodLong(m.Description, spec.schemaPath, paramsOnly) + setMethodHelpData(cmd, spec.serviceName, m.ID, spec.schemaPath, paramsOnly) // Group flags for the grouped --help renderer (typed param flags are grouped // as API Parameters by the binder). tagFlagGroup is a no-op for flags not @@ -292,13 +338,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm tagFlagGroup(cmd.Flags(), "file", groupBody) if fl := cmd.Flags().Lookup("params"); fl != nil { annotate(fl, flagGroupAnnotation, []string{groupRaw}) - // State the precedence rule where the agent reads it: --params is the - // base, typed flags override. Only meaningful when typed flags exist. + // Keep the precedence rule on the flag's own one line (not a multi-line + // note that breaks the one-entry-per-flag rhythm an agent parses). Only + // meaningful when typed flags exist to override. if len(spec.params) > 0 { - annotate(fl, flagNoteAnnotation, []string{ - "Typed API parameter flags above are preferred.", - "If both are set, typed flags override matching keys in --params.", - }) + fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params." } } for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} { diff --git a/content_embed.go b/content_embed.go new file mode 100644 index 00000000..e4a9a48e --- /dev/null +++ b/content_embed.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package main + +import ( + "embed" + "fmt" + "io/fs" + "os" + + "github.com/larksuite/cli/cmd" + "github.com/larksuite/cli/internal/affordance" +) + +// embeddedContentFS bundles the agent-readable content that must ship in lockstep +// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's +// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md). +// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist — +// a new content type is omitted until added to the embed list. The embed must live +// in this root package because go:embed cannot reach up out of a package's dir. +// +//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md +var embeddedContentFS embed.FS + +// init wires the embedded content into the CLI. It compiles into `go build .` but +// not the single-file preview build (`go build ./main.go`), so that build stays +// self-contained (shipping no embedded content). Assembly failures warn on stderr +// rather than panicking — embedded content is nice-to-have, not load-bearing. +func init() { + if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil { + fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err) + } else { + cmd.SetEmbeddedSkillContent(sub) + } + if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil { + fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err) + } else { + affordance.SetSource(sub) + } +} diff --git a/internal/affordance/affordance.go b/internal/affordance/affordance.go new file mode 100644 index 00000000..b0e49836 --- /dev/null +++ b/internal/affordance/affordance.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package affordance is the lazily-loaded store of usage guidance for +// service-API methods. The source of truth is one markdown file per service in +// the top-level affordance/ tree (see mdparse.go), injected via SetSource so +// domain owners maintain it next to skills/ and shortcuts/. A service is read +// and parsed at most once, on first access, so normal command execution never +// touches it. +package affordance + +import ( + "encoding/json" + "io/fs" + "strings" + "sync" + + "github.com/larksuite/cli/internal/apicatalog" + "github.com/larksuite/cli/internal/registry" +) + +var ( + mu sync.Mutex + byService = map[string]map[string]json.RawMessage{} + tried = map[string]bool{} + mdSource fs.FS // top-level affordance/*.md tree; nil in the minimal preview build +) + +// SetSource installs the markdown guidance tree (the top-level affordance/ +// directory) as the source. Called once at startup before any lookup; clears +// the parse cache so re-sourcing (e.g. in tests) takes effect. +func SetSource(fsys fs.FS) { + mu.Lock() + defer mu.Unlock() + mdSource = fsys + byService = map[string]map[string]json.RawMessage{} + tried = map[string]bool{} +} + +// For returns the raw affordance overlay for one method, loading the owning +// service on first access. ok is false when there is no entry (absent source, +// parse failure, or unknown method all collapse to "no guidance"). +func For(service, methodID string) (json.RawMessage, bool) { + mu.Lock() + defer mu.Unlock() + if !tried[service] { + tried[service] = true + byService[service] = loadService(service) + } + raw, ok := byService[service][methodID] + return raw, ok && len(raw) > 0 +} + +// loadService parses a service's markdown guidance into per-method overlays, +// marshalling each to JSON so downstream callers keep the same wire shape. +func loadService(service string) map[string]json.RawMessage { + if mdSource == nil { + return nil + } + src, err := fs.ReadFile(mdSource, service+".md") + if err != nil { + return nil + } + m := map[string]json.RawMessage{} + for id, a := range parseDomainMD(src, commandFormResolver(service)) { + if b, err := json.Marshal(a); err == nil { + m[id] = b + } + } + return m +} + +// commandFormResolver maps a method's command-form heading ("user_mailbox.messages +// list") to its method id ("user_mailbox.message.list") via the registry's +// authoritative resource↔id table. Resource names are irregularly pluralised +// (message/messages, user_mailbox/user_mailboxes), so this cannot be guessed; the +// space→dot fallback covers domains where the two already coincide. +func commandFormResolver(service string) func(string) string { + byForm := map[string]string{} + for _, svc := range registry.EmbeddedServicesTyped() { + if svc.Name != service { + continue + } + for _, ref := range apicatalog.ServiceMethods(svc, nil) { + byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID + } + break + } + return func(h string) string { + h = strings.TrimSpace(h) + if id, ok := byForm[h]; ok { + return id + } + return strings.ReplaceAll(h, " ", ".") + } +} diff --git a/internal/affordance/affordance_test.go b/internal/affordance/affordance_test.go new file mode 100644 index 00000000..a72899a8 --- /dev/null +++ b/internal/affordance/affordance_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package affordance + +import ( + "encoding/json" + "testing" + "testing/fstest" +) + +// fixtureMD is a minimal affordance source: two methods, each with a lead +// paragraph (use_when) and a fenced example. +const fixtureMD = "# approval\n" + + "> skill: lark-approval\n\n" + + "## instances cc\n" + + "把一个审批实例抄送给指定用户。\n\n" + + "### Examples\n\n" + + "**抄送给用户**\n" + + "```bash\n" + + "lark-cli approval instances cc --data '{\"instance_code\":\"x\"}'\n" + + "```\n\n" + + "## instances get\n" + + "查询某审批实例详情。\n\n" + + "### Examples\n\n" + + "**按 code 查询**\n" + + "```bash\n" + + "lark-cli approval instances get --instance-code \"x\"\n" + + "```\n" + +func TestFor(t *testing.T) { + prev := mdSource + t.Cleanup(func() { SetSource(prev) }) // SetSource mutates package state; restore for test isolation + SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(fixtureMD)}}) + + // A seeded method in a seeded service resolves to its overlay. + raw, ok := For("approval", "instances.cc") + if !ok { + t.Fatal(`For("approval","instances.cc") ok=false, want an overlay`) + } + var a struct { + UseWhen []string `json:"use_when"` + Examples []struct { + Command string `json:"command"` + } `json:"examples"` + } + if err := json.Unmarshal(raw, &a); err != nil { + t.Fatalf("overlay is not valid affordance JSON: %v", err) + } + if len(a.UseWhen) == 0 || len(a.Examples) == 0 || a.Examples[0].Command == "" { + t.Errorf("overlay missing use_when/examples: %s", raw) + } + + // Misses: unknown method in a known service, and an unknown service, both + // resolve to ok=false (no panic, no error) so callers treat them as "no + // guidance". + if _, ok := For("approval", "instances.no_such_method"); ok { + t.Error("unknown method should be ok=false") + } + if _, ok := For("no_such_service", "x.y"); ok { + t.Error("unknown service should be ok=false") + } + + // A second lookup of the same service is served from cache (parsed at most + // once) and stays consistent. + if _, ok := For("approval", "instances.get"); !ok { + t.Error("second lookup in a cached service should still resolve") + } +} + +// Non-bullet paragraph lines under any section are preserved as items, not +// dropped (regression: they previously only updated pending, lost without a fence). +func TestParseDomainMD_ParagraphNotDropped(t *testing.T) { + md := "# d\n\n## foo bar\nwhat it does.\n\n### Tips\n- a bullet\nplain paragraph note.\n\n### See also\nrun [[other cmd]] first.\n" + got := parseDomainMD([]byte(md), nil) // nil resolver -> space->dot, "foo bar" -> "foo.bar" + a, ok := got["foo.bar"] + if !ok { + t.Fatal("method not parsed") + } + if len(a.Tips) != 2 || a.Tips[1] != "plain paragraph note." { + t.Errorf("Tips paragraph dropped: %v", a.Tips) + } + if len(a.Extensions) != 1 || len(a.Extensions[0].Items) != 1 || a.Extensions[0].Items[0] != "run `other cmd` first." { + t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions) + } +} diff --git a/internal/affordance/mdparse.go b/internal/affordance/mdparse.go new file mode 100644 index 00000000..8ef7d251 --- /dev/null +++ b/internal/affordance/mdparse.go @@ -0,0 +1,180 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package affordance + +import ( + "regexp" + "strings" + + "github.com/larksuite/cli/internal/meta" +) + +// The affordance source is a narrow, fixed markdown subset (see src/*.md): +// +// # domain optional `> skill: ` applied to every method +// ## command e.g. `instances get` +// -> use_when (when this command is right) +// ### Avoid when -> avoid_when (links become prefer/alternative edges) +// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge) +// ### Tips -> tips +// ### Examples -> examples: **description** + a ```fenced``` command +// ### -> extensions[] (custom section, flows through verbatim) +// [[cmd]] -> a command reference, rendered as `cmd` +// +// Parsing is lazy and cached (see For), so the constrained grammar is read at +// most once per domain. + +var mdLink = regexp.MustCompile(`\[\[(.+?)\]\]`) + +// standardSection maps a section heading to its typed Affordance field; any +// other heading becomes an extension. +var standardSection = map[string]string{ + "Avoid when": "avoid_when", + "Prerequisites": "prerequisites", + "Tips": "tips", + "Examples": "examples", +} + +func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") } + +// headingToKey maps a command heading ("instances get") to its affordance key +// ("instances.get"). The space→dot rule holds where the command form matches +// the method id; domains whose resource names differ (e.g. plural "messages" +// vs id segment "message") need the registry's authoritative resource↔id table. +func headingToKey(h string) string { + return strings.ReplaceAll(strings.TrimSpace(h), " ", ".") +} + +type mdSection struct { + label string + items []string + cases []meta.AffordanceCase +} + +// parseDomainMD parses one domain's markdown into per-method Affordance values, +// keyed by method id. resolve maps a command-form heading ("user_mailbox.messages +// list") to its method id ("user_mailbox.message.list"); nil falls back to the +// space→dot rule (valid only where the command form already equals the id). +func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affordance { + if resolve == nil { + resolve = headingToKey + } + out := map[string]meta.Affordance{} + + var skill, curKey string + var useWhen, para []string // lead paragraphs -> use_when entries (blank line separates) + var secs []*mdSection + var sec *mdSection + var pending string + var fence []string + inFence := false + + assemble := func() { + if curKey == "" { + return + } + if len(para) > 0 { + useWhen = append(useWhen, strings.TrimSpace(strings.Join(para, " "))) + para = nil + } + var a meta.Affordance + if len(useWhen) > 0 { + a.UseWhen = useWhen + } + for _, s := range secs { + switch standardSection[s.label] { + case "avoid_when": + a.AvoidWhen = s.items + case "prerequisites": + a.Prerequisites = s.items + case "tips": + a.Tips = s.items + case "examples": + a.Examples = s.cases + default: + a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items}) + } + } + if skill != "" { + a.Skills = []string{skill} + } + out[curKey] = a + } + + reset := func() { useWhen, para, secs, sec, pending, fence, inFence = nil, nil, nil, nil, "", nil, false } + + // flushPending appends a non-bullet paragraph line that was not consumed as + // an example description (i.e. no fence followed) to the current section's + // items, so prose under any section is preserved rather than dropped. + flushPending := func() { + if sec != nil && pending != "" { + sec.items = append(sec.items, linkToBacktick(pending)) + pending = "" + } + } + + for _, raw := range strings.Split(string(src), "\n") { + line := strings.TrimRight(raw, "\r") + t := strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "## "): + flushPending() + assemble() + curKey = resolve(line[3:]) + reset() + continue + case strings.HasPrefix(line, "# "): + continue + case strings.HasPrefix(t, "> skill:"): + skill = strings.TrimSpace(t[len("> skill:"):]) + continue + case strings.HasPrefix(line, "### "): + flushPending() + sec = &mdSection{label: strings.TrimSpace(line[4:])} + secs = append(secs, sec) + pending, fence, inFence = "", nil, false + continue + } + if curKey == "" { + continue + } + if sec == nil { // lead paragraphs before any section -> use_when (blank line separates entries) + if t == "" { + if len(para) > 0 { + useWhen = append(useWhen, strings.Join(para, " ")) + para = nil + } + } else { + para = append(para, t) + } + continue + } + // inside a section: a fenced block is an example command; otherwise the + // shape follows the writing (bullet item vs **description** before a fence). + if strings.HasPrefix(t, "```") { + if !inFence { + inFence, fence = true, nil + } else { + inFence = false + sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")}) + pending = "" + } + continue + } + if inFence { + fence = append(fence, line) + continue + } + if strings.HasPrefix(t, "-") { + flushPending() + sec.items = append(sec.items, linkToBacktick(strings.TrimSpace(t[1:]))) + } else if t != "" { + flushPending() + pending = strings.Trim(t, "* ") + } + } + flushPending() + assemble() + return out +} diff --git a/internal/meta/affordance.go b/internal/meta/affordance.go index ef0de661..77af8647 100644 --- a/internal/meta/affordance.go +++ b/internal/meta/affordance.go @@ -5,30 +5,39 @@ package meta import "encoding/json" -// Affordance is the hand-authored usage guidance overlaid on a method: when to -// use it, when not to, prerequisites, few-shot examples, and related methods. -// It is the single typed model of the affordance shape; the envelope renderer -// and the command help both parse through ParsedAffordance so the vocabulary -// is defined once. The JSON tags double as the envelope's wire shape. +// Affordance is the typed usage guidance overlaid on a method. It is the single +// model the envelope renderer and the command help both parse, so the +// vocabulary is defined once; the JSON tags double as the envelope wire shape. +// Skills entries are skill names (or name/path) rendered as runnable +// `lark-cli skills read ` pointers. type Affordance struct { - UseWhen []string `json:"use_when,omitempty"` - DoNotUseWhen []string `json:"do_not_use_when,omitempty"` - Prerequisites []string `json:"prerequisites,omitempty"` - Examples []AffordanceCase `json:"examples,omitempty"` - Related []string `json:"related,omitempty"` + UseWhen []string `json:"use_when,omitempty"` + AvoidWhen []string `json:"avoid_when,omitempty"` + Prerequisites []string `json:"prerequisites,omitempty"` + Tips []string `json:"tips,omitempty"` + Examples []AffordanceCase `json:"examples,omitempty"` + Extensions []AffordanceSection `json:"extensions,omitempty"` + Related []string `json:"related,omitempty"` + Skills []string `json:"skills,omitempty"` } -// AffordanceCase is one few-shot example: a one-line description and a -// ready-to-run command. +// AffordanceCase is one few-shot example: a description and a ready-to-run command. type AffordanceCase struct { - Description string `json:"description"` + Description string `json:"description,omitempty"` Command string `json:"command"` } -// ParsedAffordance decodes the method's raw affordance overlay into the typed -// Affordance. ok is false when the method carries no affordance, the JSON is -// malformed, or every section is empty — so callers can treat "no guidance" -// uniformly. +// AffordanceSection is a custom guidance section: any heading beyond the +// standard four (Avoid when / Prerequisites / Tips / Examples) flows through +// here with its label preserved, so authors can add sections without code +// changes. +type AffordanceSection struct { + Label string `json:"label"` + Items []string `json:"items,omitempty"` +} + +// ParsedAffordance decodes the method's overlay. ok is false when it is absent, +// malformed, or wholly empty — callers treat all three as "no guidance". func (m Method) ParsedAffordance() (Affordance, bool) { if len(m.Affordance) == 0 { return Affordance{}, false @@ -37,7 +46,7 @@ func (m Method) ParsedAffordance() (Affordance, bool) { if json.Unmarshal(m.Affordance, &a) != nil { return Affordance{}, false } - if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 { + if len(a.UseWhen) == 0 && len(a.AvoidWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Tips) == 0 && len(a.Examples) == 0 && len(a.Extensions) == 0 && len(a.Related) == 0 && len(a.Skills) == 0 { return Affordance{}, false } return a, true diff --git a/internal/meta/affordance_test.go b/internal/meta/affordance_test.go index 4dd7665a..199e18d2 100644 --- a/internal/meta/affordance_test.go +++ b/internal/meta/affordance_test.go @@ -19,7 +19,7 @@ func TestMethod_ParsedAffordance(t *testing.T) { notOK := map[string]string{ "empty payload": ``, "empty object": `{}`, - "all empty arrays": `{"use_when":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`, + "all empty arrays": `{"use_when":[],"avoid_when":[],"prerequisites":[],"tips":[],"examples":[],"related":[]}`, "malformed string": `"not an object"`, "malformed number": `42`, "nested type mismatch": `{"examples":"should be a list"}`, @@ -35,8 +35,9 @@ func TestMethod_ParsedAffordance(t *testing.T) { // Populated affordance parses with all fields. raw := `{ "use_when": ["需要拿到当前用户的主日历 ID"], - "do_not_use_when": ["已知具体 calendar_id"], + "avoid_when": ["已知具体 calendar_id"], "prerequisites": ["user 身份登录"], + "tips": ["主日历的 calendar_id 即当前用户的 union_id"], "examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}], "related": ["calendars.list"] }` @@ -47,10 +48,22 @@ func TestMethod_ParsedAffordance(t *testing.T) { if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" { t.Errorf("UseWhen = %v", a.UseWhen) } + if len(a.Tips) != 1 || a.Tips[0] != "主日历的 calendar_id 即当前用户的 union_id" { + t.Errorf("Tips = %v", a.Tips) + } if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || a.Examples[0].Command != "lark-cli calendar calendars primary" { t.Errorf("Examples = %+v", a.Examples) } if len(a.Related) != 1 || a.Related[0] != "calendars.list" { t.Errorf("Related = %v", a.Related) } + + // A method whose only guidance is Tips still parses as populated. + tipsOnly, ok := (Method{Affordance: json.RawMessage(`{"tips":["先调用 list 拿到 id"]}`)}).ParsedAffordance() + if !ok { + t.Fatal("ParsedAffordance with only tips ok=false, want populated") + } + if len(tipsOnly.Tips) != 1 || tipsOnly.Tips[0] != "先调用 list 拿到 id" { + t.Errorf("Tips = %v", tipsOnly.Tips) + } } diff --git a/internal/schema/assembler.go b/internal/schema/assembler.go index 92200119..6b1c43f0 100644 --- a/internal/schema/assembler.go +++ b/internal/schema/assembler.go @@ -4,8 +4,11 @@ package schema import ( + "regexp" "sort" + "strings" + "github.com/larksuite/cli/internal/affordance" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/meta" @@ -22,7 +25,7 @@ func Convert(f meta.Field) Property { if f.Type == "file" { p.Format = "binary" } - p.Description = f.Description + p.Description = normalizeDesc(f.Description) p.Default = f.CoercedDefault() p.Example = f.CoercedExample() p.Minimum = f.MinBound() @@ -52,6 +55,24 @@ func Convert(f meta.Field) Property { return p } +var ( + sepRunRe = regexp.MustCompile(`[;;]{2,}`) + spaceRunRe = regexp.MustCompile(`[ \t]{2,}`) +) + +// normalizeDesc de-crufts a meta_data description for the envelope — strips +// markdown emphasis and collapses doubled separators/spaces — but keeps content +// (links, newlines, sentences); the compact flag-help has its own stricter pass. +func normalizeDesc(s string) string { + if s == "" { + return "" + } + s = strings.ReplaceAll(s, "**", "") + s = sepRunRe.ReplaceAllString(s, "; ") + s = spaceRunRe.ReplaceAllString(s, " ") + return strings.TrimRight(s, " ;;。.,,、\n") +} + // enumSchema splits coerced enum options into the parallel enum / enumDescriptions // arrays for the envelope. enumDescriptions is nil unless at least one value // carries a description (so the bare-enum form stays values-only), keeping the @@ -86,6 +107,18 @@ func propsOf(fields []meta.Field) *OrderedProps { return op } +// paramPropsOf is propsOf for the params section: each property also carries +// its CLI flag (--kebab-name). +func paramPropsOf(fields []meta.Field) *OrderedProps { + op := &OrderedProps{} + for _, f := range fields { + p := Convert(f) + p.Flag = "--" + f.FlagName() + op.Set(f.Name, p) + } + return op +} + // requiredOf returns the alphabetized names of the required fields. func requiredOf(fields []meta.Field) []string { var required []string @@ -108,16 +141,17 @@ func buildInputSchema(m meta.Method) *InputSchema { Properties: &OrderedProps{}, } - addInputObject(is, "params", "", m.Params()) - addInputObject(is, "data", "", m.Data()) - addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file =.", m.Files()) + addInputObject(is, "params", "", m.Params(), true, "") + addInputObject(is, "data", "", m.Data(), false, "--data") + addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file =.", m.Files(), false, "--file") if m.Risk == core.RiskHighRiskWrite { falseVal := false is.Properties.Set("yes", Property{ Type: "boolean", + Flag: "--yes", Default: falseVal, - Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.", + Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Pass --yes only after the user has explicitly confirmed; not sent to the backend.", }) } @@ -125,20 +159,24 @@ func buildInputSchema(m meta.Method) *InputSchema { return is } -// addInputObject adds one named sub-object section (params/data/file) to the -// input schema when it has fields: its Properties come from the fields, its -// Required lists the mandatory keys, and the section itself is required at top -// level when any field is required. Empty sections are skipped. -func addInputObject(is *InputSchema, name, description string, fields []meta.Field) { +// addInputObject adds one section (params/data/file) when it has fields, marking +// the section required at top level when any field is. asFlags tags each property +// with its --flag (params only); carrier names the section's flag (--data/--file). +func addInputObject(is *InputSchema, name, description string, fields []meta.Field, asFlags bool, carrier string) { if len(fields) == 0 { return } + props := propsOf(fields) + if asFlags { + props = paramPropsOf(fields) + } req := requiredOf(fields) is.Properties.Set(name, Property{ Type: "object", Description: description, + Carrier: carrier, Required: req, - Properties: propsOf(fields), + Properties: props, }) if len(req) > 0 { is.Required = append(is.Required, name) @@ -179,7 +217,13 @@ func buildMeta(m meta.Method) *Meta { // EnvelopeOf renders the MCP envelope for one method ref — the ref-based entry // callers use, since apicatalog.MethodRef is the metadata navigation currency. func EnvelopeOf(ref apicatalog.MethodRef) Envelope { - return assemble(ref.Service.Name, ref.ResourcePath, ref.Method) + m := ref.Method + // The affordance overlay lives in the CLI, not the metadata; look it up + // lazily here (it takes precedence over any affordance the metadata carries). + if raw, ok := affordance.For(ref.Service.Name, m.ID); ok { + m.Affordance = raw + } + return assemble(ref.Service.Name, ref.ResourcePath, m) } // Envelopes renders the given method refs into envelopes, sorted by name. The @@ -205,7 +249,7 @@ func assemble(serviceName string, resourcePath []string, m meta.Method) Envelope return Envelope{ Name: name, - Description: m.Description, + Description: normalizeDesc(m.Description), InputSchema: buildInputSchema(m), OutputSchema: buildOutputSchema(m), Meta: buildMeta(m), diff --git a/internal/schema/assembler_test.go b/internal/schema/assembler_test.go index 0465c41d..b6bd363b 100644 --- a/internal/schema/assembler_test.go +++ b/internal/schema/assembler_test.go @@ -9,7 +9,9 @@ import ( "reflect" "strings" "testing" + "testing/fstest" + "github.com/larksuite/cli/internal/affordance" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/registry" @@ -504,6 +506,31 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) { } } +// EnvelopeOf injects affordance from the CLI overlay (looked up lazily by +// service + method id), so a method whose metadata carries none still gets +// guidance in its envelope when an overlay entry exists. +func TestEnvelopeOf_AffordanceFromOverlay(t *testing.T) { + // The overlay source is the top-level affordance/ tree, injected at startup; + // inject a fixture so this unit test does not depend on the shipped content. + // Reset afterwards (this binary installs no source by default) for isolation. + t.Cleanup(func() { affordance.SetSource(nil) }) + affordance.SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte( + "# approval\n> skill: lark-approval\n\n## instances get\n查询某审批实例的状态与进度。\n\n### Examples\n\n**按 code 查询**\n```bash\nlark-cli approval instances get --instance-code \"x\"\n```\n")}}) + env := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.get", Name: "get"}) + if env.Meta == nil || env.Meta.Affordance == nil { + t.Fatal("expected affordance from the approval overlay, got none") + } + if len(env.Meta.Affordance.UseWhen) == 0 || len(env.Meta.Affordance.Examples) == 0 { + t.Errorf("overlay affordance missing use_when/examples: %+v", env.Meta.Affordance) + } + + // A method id with no overlay entry carries no affordance. + bare := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.no_such_method", Name: "x"}) + if bare.Meta != nil && bare.Meta.Affordance != nil { + t.Errorf("method without overlay should have no affordance, got %+v", bare.Meta.Affordance) + } +} + func TestBuildMeta_MissingDocURLOmitted(t *testing.T) { method := map[string]interface{}{ "scopes": []interface{}{"x"}, diff --git a/internal/schema/types.go b/internal/schema/types.go index 084ca469..d7e3e1c5 100644 --- a/internal/schema/types.go +++ b/internal/schema/types.go @@ -13,6 +13,10 @@ import ( ) // Envelope is the MCP Tool spec contract for a single API method command. +// +// The REST route (httpMethod/path) is deliberately NOT exposed: every +// schema-resolvable method already has a typed command, so the raw path would +// only tempt an agent toward the `api` escape hatch. type Envelope struct { Name string `json:"name"` Description string `json:"description"` @@ -44,9 +48,15 @@ type OutputSchema struct { // "params" / "data" sub-objects inside inputSchema): it lists which keys // inside that object's Properties are mandatory. Leaf fields ignore it. type Property struct { - Type string `json:"type,omitempty"` - Description string `json:"description,omitempty"` - Enum []interface{} `json:"enum,omitempty"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + // Flag is the typed CLI flag a params property maps to (e.g. "--folder-id"); + // absent on body/file fields, which travel via the section's Carrier. + Flag string `json:"flag,omitempty"` + // Carrier names the flag a whole inputSchema section travels on ("--data" / + // "--file"); empty on the params section, whose properties carry their Flag. + Carrier string `json:"carrier,omitempty"` + Enum []interface{} `json:"enum,omitempty"` // EnumDescriptions, when present, is parallel to Enum: the human meaning of // each allowed value, in the same order. Omitted when no value carries a // description. This is the widely-recognized JSON-Schema extension (VS Code, diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index 0a1b2731..ea618304 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -8,10 +8,6 @@ metadata: cliHelp: "lark-cli contact --help" --- -# contact (v2) - -**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** - ## 选哪个命令 **user 身份和 bot 身份是两条完全独立的路径**。先确定当前身份,再按下表选命令: diff --git a/skills/lark-contact/references/lark-contact-search-user.md b/skills/lark-contact/references/lark-contact-search-user.md index ed346da4..6ef3d78d 100644 --- a/skills/lark-contact/references/lark-contact-search-user.md +++ b/skills/lark-contact/references/lark-contact-search-user.md @@ -1,13 +1,12 @@ # +search-user -仅 user 身份。需要 scope `contact:user:search`。 +仅支持 user 身份。 ## 适用范围 - ✅ 已知姓名 / 邮箱 / 「聊过的人」想找出 open_id - ✅ 已知一组 open_id 想批量校验或回填字段(`--user-ids`,最多 100,支持 `me`) - ✅ 按聊天关系 / 在职状态 / 租户边界 / 企业邮箱等维度筛选员工 -- ❌ 已知 open_id 想拿完整 profile → 用 `+get-user --as bot` - ❌ 已知 open_id 想发消息 → 直接走 `lark-im`,不经过本命令 ## 关键 flag diff --git a/skills_embed.go b/skills_embed.go deleted file mode 100644 index c5cdbbf3..00000000 --- a/skills_embed.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package main - -import ( - "embed" - "fmt" - "io/fs" - "os" - - "github.com/larksuite/cli/cmd" -) - -// skillsEmbedFS embeds each skill's agent-readable content (SKILL.md + -// references/, plus lark-whiteboard's routes/ and scenes/) so the CLI serves -// content matching the binary version; machine-resource dirs (assets/, scripts/) -// are excluded, saving ~3.3 MB. It's a whitelist — a new subdirectory type is -// silently omitted until added here. -// -//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes -var skillsEmbedFS embed.FS - -// init wires the embedded tree in as the default skill content. It compiles into -// `go build .` but not the single-file preview build (`go build ./main.go`), so -// main.go stays self-contained and that build still compiles (shipping no -// embedded skills). Assembly failure warns on stderr rather than panicking. -func init() { - sub, err := fs.Sub(skillsEmbedFS, "skills") - if err != nil { - fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err) - return - } - cmd.SetEmbeddedSkillContent(sub) -}