Commit Graph

254 Commits

Author SHA1 Message Date
fangshuyu-768
7c54f9b023 feat(drive): switch markdown export to V2 docs_ai fetch API (#948)
Switch `drive +export --file-extension markdown` from the legacy V1
GET /open-apis/docs/v1/content API to the V2
POST /open-apis/docs_ai/v1/documents/{token}/fetch API for
higher-quality Lark-flavored Markdown output.

- Update DryRun and Execute paths to use V2 endpoint with JSON body
- Add docx:document:readonly scope for the new API
- Validate V2 response structure (fail fast on missing document/content)
- Encode token in URL path via validate.EncodePathSegment
- Update unit tests and add V2 response validation error path tests
- Add E2E dry-run test for markdown export path
- Update skill documentation
2026-05-19 17:53:54 +08:00
liangshuo-1
e6bc292575 fix(identitydiag): harden verify path and tighten status semantics (#961)
* fix(identitydiag): harden verify path and tighten status semantics

Follow-ups to #957:

- bound bot/user verify calls with a 10s timeout (mirrors the doctor
  endpoint probe) so a hanging server cannot wedge `auth status --verify`
  or `doctor`
- return StatusNotConfigured (not StatusMissing) when the user-identity
  path is blocked by missing app config, matching the bot side
- surface the `{code, msg}` envelope on bot-info HTTP 4xx responses so
  callers see why bot auth was rejected, not just the bare HTTP code
- introduce identity{User,Bot,None} constants in cmd/auth/status.go and
  use the exported StatusMessage() in the human-readable note instead of
  raw status codes like "not_configured"
- collapse the duplicated verify-failed identity construction in the
  user path into a local helper
- cover the new failure paths with unit tests (HTTP 4xx with envelope,
  business error code, user server-rejected, expired user token,
  strict-mode user-only, missing app config for user)

Change-Id: I581348a65f15b1452a6f48a3e3245d09257314ac

* fix(identitydiag): decode bot/v3/info from "bot" field, not "data"

`/open-apis/bot/v3/info` returns `{code, msg, bot: {...}}` — the bot
payload is under `bot`, not `data` as the newer Lark API convention
would suggest. The decoder was reading from a non-existent `data`
field, so `envelope.Data.OpenID` was always empty and every successful
verify was reported as `Bot identity: verify failed: open_id is empty`.

The pre-existing test mocks used `{"data": {...}}` matching the buggy
decoder, so unit tests passed while production reads of every Lark
account failed verification.

Fix:
- change the JSON tag on the envelope from `json:"data"` to `json:"bot"`
- update mocks in identitydiag and cmd/auth/status tests to emit `bot`

Verified locally: `lark-cli doctor` now reports `bot_identity: pass`
for both a normal account and a bot-only profile, restoring the
behavior that #957 set out to deliver.

Change-Id: Ib26dfdd5a0cc37d2d62537ae2bf5e854e67cb83c

* fix(shortcuts/common): decode bot/v3/info from "bot" field, not "data"

Same schema bug as the one fixed in identitydiag — `RuntimeContext.
fetchBotInfo` reads from a non-existent "data" key, so every successful
call would report "open_id is empty" once a caller starts depending on
it.

There are no production callers of `RuntimeContext.BotInfo()` yet
(only tests + the `TestNewRuntimeContextWithBotInfo` helper), so this
bug is dormant — but the pre-existing tests pass with the same wrong
schema in their mocks, so the first real consumer would silently break.

Fix: tag `json:"data"` → `json:"bot"` plus aligning the four mock
fixtures in runner_botinfo_test.go. The Go field name `Data` is kept
to minimize the diff; only the JSON contract is corrected.

Change-Id: I11e1e871603e5349f8df29b1d58e35d07b628dfd
2026-05-19 15:50:40 +08:00
fangshuyu-768
4aa61db8b2 feat(drive): add +inspect shortcut for document URL inspection with wiki unwrapping (#947)
* feat(drive): add +inspect shortcut for document URL inspection with wiki unwrapping

Implements #662: `lark-cli drive +inspect --url <url>` inspects any
Lark/Feishu document URL to get its type, title, and canonical token,
with automatic wiki URL unwrapping via get_node API.

- Add ParseResourceURL (inverse of BuildResourceURL) in common
- Extract FetchDriveMetaTitle as public shared helper
- Add drive +inspect shortcut with wiki unwrapping support
- Add skill reference docs and update SKILL.md
- Dry-run E2E tests for docx URL, wiki URL, and bare token

* refactor: move host validation from ParseResourceURL to +inspect

ParseResourceURL is a general-purpose URL parser that should not
hardcode domain lists — future Lark domains would silently break.
Move isLarkHost/larkHostSuffixes to drive_inspect.go where host
validation is a business decision of the +inspect command.
Add E2E test for non-Lark host with Lark-like path.

* refactor: remove host validation from +inspect

Lark supports custom enterprise domains, so a hardcoded suffix list
can never be exhaustive and would falsely reject valid URLs.
Path-based matching in ParseResourceURL is sufficient; invalid URLs
will fail naturally at the API call stage.
2026-05-19 15:19:35 +08:00
liujinkun2025
28c66be199 fix(wiki): surface real node url for +node-create / +node-copy (#960)
* fix(wiki): surface real node url for +node-create / +node-copy

The create-node and copy-node OpenAPI responses carry a real `url`
field (present in practice though absent from the documented schema).
Both shortcuts ignored it: +node-create synthesized a link via
BuildResourceURL, and +node-copy emitted no URL at all.

Parse `url` into the shared wikiNodeRecord and add a wikiNodeURL helper
that prefers the response url, falling back to BuildResourceURL only
when it is blank. Wire +node-create and +node-copy to the helper so
both surface the canonical link when available.

Change-Id: I0ca5f91b02c24e81d083793e6a8e4f8c966aeec3

* refactor(wiki): move wikiNodeURL to shared wiki_helpers.go

The helper is consumed by both +node-create and +node-copy, so its
placement should reflect the broader usage rather than living in the
create command's file. Pure move; no behavior change.

Change-Id: I9990c12da042f631fe2519911c6a9d663fd5c22b
2026-05-19 15:19:15 +08:00
xzcong0820
0e70b056f8 feat(mail): bot+mailbox=me validation and dynamic --as help tests (#895)
* feat(mail): bot+mailbox=me validation and dynamic --as help tests

Add validateBotMailboxNotMe helper to shortcuts/mail/helpers.go and
wire it as a Validate callback into +message, +messages, +thread and
+triage, so bot identity combined with the default --mailbox me is
rejected early with a clear fixup hint instead of a late opaque API
error.

The --as help text was already dynamic via AddShortcutIdentityFlag;
add TC-10/TC-11 tests in internal/cmdutil/identity_flag_test.go to
pin that behaviour, and TC-1 through TC-9 in
shortcuts/mail/mail_shortcut_validation_test.go to cover the new
Validate callbacks.

+watch is excluded: its AuthTypes is ["user"], so bot is never valid.

sprint: S2

* test(cmdutil): add Hidden and DefValue assertions to identity flag tests

* fix(mail): add bot+mailbox=me validation to +template-create and +template-update

* fix(mail): add bot+mailbox=me validation to +template-update

* fix(mail): gofmt mail_template_create.go

* fix(mail): gofmt mail_template_update.go

* fix(mail): skip bot+mailbox=me check for print-patch-template local path
2026-05-19 15:07:43 +08:00
xzcong0820
e511404065 feat(mail): expose draft priority in --inspect projection and document --set-priority (#779)
Add a Priority field to DraftProjection populated from the EML header pair
X-Cli-Priority (CLI/OAPI primary) → X-Priority (RFC fallback for IMAP-回灌
historical drafts), with case-insensitive lookup via the existing
headerValue helper and a local mapping table aligned with the backend
gopkg/mail_priority.PriorityValueToType vocabulary. When neither header is
present (the symmetric read of --set-priority normal=remove_header) the
projection emits "unknown" so agents have a stable read-side surface.

Append one notes entry to buildDraftEditPatchTemplate documenting the
--set-priority flag and the X-Cli-Priority translation contract.

The write-side (--set-priority flag, parsePriority helper, translation
branch in mail_draft_edit.go, EML header target) is unchanged — already
shipped on master.

sprint: S4
2026-05-19 14:02:01 +08:00
zgz2048
3354494579 fix: address Base attachment review follow-ups (#958) 2026-05-19 13:20:07 +08:00
zgz2048
2bb69d1942 feat: support Base attachment APIs (#887)
* feat: support base attachment APIs

* fix: handle duplicate base attachment downloads

* fix: remove unused attachment token helper
2026-05-19 11:52:47 +08:00
liujinkun2025
c4fb7006d2 feat(wiki): add +node-get / +node-delete / +space-create shortcuts (#904)
- +node-get: wrap wiki.spaces.get_node; accepts node_token, obj_token,
  or a Lark URL (URL path auto-infers obj_type); formatted output with
  creator / updated_at. No synthesized url — get_node returns none and a
  BuildResourceURL fallback is a non-canonical link that misleads in a
  read/confirm command (sibling read shortcuts omit it too)
- +node-delete: wrap space.node delete; high-risk-write (--yes gated),
  async delete-node task polling, auto-resolves space_id via get_node
  when --space-id omitted, actionable hints for codes 131011 / 131003.
  The delete-node task result lives under the gateway's generic
  `simple_task_result` key (NOT `delete_node_result`)
- +space-create: wrap spaces.create; user-only identity, --name
  required (no empty-name spaces), flattened space output, no url
- factor the shared wiki async-task poll loop into wiki_async_task.go;
  preserve upstream Lark Detail.Code on poll exhaustion (no longer
  rebuilt via lossy ErrWithHint)
- drive +task_result: add wiki_delete_node scenario so +node-delete's
  async-timeout next_command actually resolves
- skill docs: reference pages for the 3 new shortcuts + SKILL.md
  shortcuts table (no raw nodes.delete API exists — it's shortcut-only,
  so it is intentionally absent from API Resources / permission table);
  drop the circular TestWikiShortcutsIncludeAllCommands change-detector

Change-Id: I316f78290cec5bc50f80d629173e3bf2a35dd005
2026-05-19 11:21:54 +08:00
afengzi
583349e572 fix(docs): clarify replace_all selection errors (#954) 2026-05-19 10:54:49 +08:00
wangweiming-01
de00343063 feat: add markdown +patch shortcut (#857)
* feat: add markdown +patch shortcut

Change-Id: I8159941ff9dec4e5cbf0c757ec19ee172b302224

* fix: align markdown patch validation and dry-run

Change-Id: I98079901e980b74998938afc4917b91a79689948
2026-05-18 20:54:11 +08:00
fangshuyu-768
df4b657737 feat(drive): add +sync workflow for Drive directories (#873)
Bidirectional sync between a local directory and a Drive folder with
diff detection (new_local, new_remote, modified, unchanged) and
conflict resolution strategies (--on-conflict: remote-wins, local-wins,
keep-both, ask).

Key behaviors:
- Type conflict detection: hard-fail when local file vs remote non-file
  or local directory vs remote file
- Keep-both: rename local with __lark_<hash> suffix, then pull remote;
  occupied map includes localDirs to prevent suffix collision
- Local-wins partial-success: prefer returned file_token on upload failure
- Empty directory mirroring: pre-create local dirs on Drive via
  drivePushWalkLocal before scope preflight
- Structured errors throughout (output.Errorf / output.ErrWithHint)

Includes unit tests and E2E tests (dry-run + live workflow).
2026-05-18 19:56:43 +08:00
史启明(QimingShi)
4b721c0410 fix(sheets): explicitly document safe JSON unmarshal ignore in DryRun (#935)
Two DryRun functions in the sheets shortcuts called json.Unmarshal without
checking the return value. This looks like a bug, but Validate already
parses and validates the same --style / --data JSON before DryRun runs,
so the error is structurally impossible at this point.

Use _ = assignment + comment to silence the unchecked-error lint warning
and make the safety invariant explicit to future readers.

Co-authored-by: KhanCold <KhanCold@users.noreply.github.com>
2026-05-18 17:34:18 +08:00
wangweiming-01
241952459d feat: add drive version shortcut (#841)
Change-Id: I87bb32c86e3c3362f541ccc6320c656eb795ec9b
2026-05-18 16:44:10 +08:00
sang-neo03
33c292c05e feat(extension): Plugin / Hook framework with command pruning (#910)
* feat(extension): introduce Plugin / Hook framework with command pruning

Add a single public extension contract under extension/platform: integrators
implement the Plugin interface and register Observers, Wrappers, Lifecycle
handlers, and pruning Rules through the Registrar in one Install call.

Command pruning:
  - Rule (Allow / Deny / MaxRisk / Identities) with doublestar globs
  - 4-axis AND evaluation, parent-group aggregation, unknown-risk allow
  - Sources: Plugin.Restrict (single-rule) and ~/.lark-cli/policy.yml
  - Plugin path is fail-closed (envelope on rule error / multiple Restrict);
    yaml path is fail-open (warning, CLI continues)
  - strict-mode stubs now also write the denial annotation so the hook
    layer's denial guard physically isolates Wrap chains on them
  - HOME path never leaked through policy_source label

Hook framework:
  - Observer (panic-safe, Before/After), Wrapper (middleware, may short-circuit
    via AbortError), Lifecycle (Startup + Shutdown only)
  - Recover guards every plugin entry point: Capabilities(), Install(),
    Wrapper factory composition AND inner Handler, Lifecycle handlers
  - namespacedWrap copies AbortError so a plugin's package-level sentinel
    is never mutated across concurrent invocations
  - Selector unknown-risk uniform: ByExactRisk / ByWrite / ByReadOnly never
    match unannotated commands; safety-side hooks opt in via
    ByWrite().Or(ByUnknownRisk())

Bootstrap orchestration (cmd/build.go + cmd/policy.go):
  - InstallAll uses a staging Registrar + atomic commit
  - FailClosed plugin install / Plugin.Restrict conflict / Startup handler
    failure each install a structured envelope guard at every dispatch path
  - walkGuard neutralises every cobra bypass we know of (PersistentPreRunE
    first-wins, ValidateArgs, ParseFlags, legacyArgs, __complete /
    __completeNoDesc, non-runnable groups, required-arg subcommands)
  - cmd/root.go::Execute calls hook.Emit(Shutdown, runErr) after
    rootCmd.Execute; isCompletionCommand skips both __complete and
    __completeNoDesc so Tab completion never triggers Shutdown handlers

Capabilities consistency:
  - Restricts=true must declare FailurePolicy=FailClosed
  - RequiredCLIVersion (semver constraint) is validated against build.Version;
    a malformed constraint is treated as untrusted-config and aborts
    unconditionally, regardless of FailurePolicy (DEV builds included)

JSON envelope contract:
  - error.type closed enum: pruning / strict_mode / hook / plugin_install /
    plugin_conflict / plugin_lifecycle
  - reason_code closed enums per type, all referenced by structured tests

Bootstrap surfaces (new user commands):
  - lark-cli config policy show     -- JSON view of the active Rule + source
  - lark-cli config policy validate -- parse + schema + glob check, no apply

Coverage:
  - extension/platform: every public type has a unit test
  - internal/{pruning,hook,platformhost,policydecision,cmdmeta}: full coverage
    of denial guard isolation, AbortError sentinel safety, observer panic
    safety, lifecycle error/panic typing, staging atomic rollback
  - cmd/plugin_integration_test.go: end-to-end through buildInternal with
    synthetic and real command trees
  - cmd/install_guard_test.go: walkGuard covers auth / config / __complete /
    __completeNoDesc / non-runnable parents

* fix(pruning): deny stub must override Args + PersistentPreRunE

The pruning denyStub and the strict-mode stub previously only swapped
RunE plus Hidden + DisableFlagParsing. Cobra's dispatch order means
several pre-RunE gates can fire BEFORE the stub's RunE ever runs:

  1. Args validator: shortcut commands often declare cobra.NoArgs.
     With DisableFlagParsing=true the user's `--doc xxx --mode append`
     looks like positional args, so ValidateArgs surfaces a usage
     error instead of the pruning / strict_mode envelope. Observer
     hooks also miss the dispatch entirely.

  2. Parent PersistentPreRunE: cmd/auth/auth.go declares a
     PersistentPreRunE that returns external_provider when env
     credentials are set. Cobra's "first PersistentPreRunE wins
     walking up from the leaf" then short-circuits with
     external_provider instead of the leaf's denial envelope.

Both stubs now also set:

  - Args               = cobra.ArbitraryArgs   (bypass gate 1)
  - PersistentPreRunE  = no-op leaf hook       (bypass gate 2)
  - PreRunE / PreRun / PersistentPreRun = nil  (defensive)

Effect: dispatch reaches the wrapped RunE, observers fire, the real
pruning / strict_mode envelope is emitted regardless of credential
provider or flag count.

Adds regression tests covering both gates on both stub paths.

* fix(config): policy subcommand bypasses parent's credential check

cmd/config/config.go::NewCmdConfig declares a PersistentPreRunE that
calls f.RequireBuiltinCredentialProvider; with env credentials set,
it returns external_provider for every config subcommand.

`config policy show` and `config policy validate` are READ-ONLY
diagnostic commands -- they inspect or parse the user-layer rule
without touching credentials. They MUST work regardless of which
credential provider is active, otherwise users on env-credential
deployments cannot debug their policy.

Same shape as the codex C11/C13 fix: install a no-op leaf-level
PersistentPreRunE on the `policy` group so cobra's "first walking up
from leaf" rule picks ours over the config parent's.

Regression caught by divergent e2e (F1-F6 all returned external_provider
before this fix; all pass after). Adds a unit test pinning the
PersistentPreRunE override.

* feat(shortcuts): tag service groups with cmdmeta.Domain

RegisterShortcutsWithContext now calls cmdmeta.SetDomain on each
service-level cobra.Command (im, docs, drive, calendar, ...) so the
business-domain axis is actually populated on every shortcut leaf via
parent-chain inheritance.

Before this change, platform.ByDomain("docs") never matched any
command: the domain annotation was unset across the entire shortcut
tree, so the selector's d != "" guard always failed and risk-style
selectors silently degraded to no-op.

The SetDomain call is placed AFTER the create-or-reuse branch so it
fires whether the service command was freshly created here or had
already been added by cmd/service/service.go's OpenAPI auto-
registration (which runs first and creates im, drive, calendar, etc.).
Without this placement only pure-shortcut services like docs would
have been tagged.

Adds a regression test asserting:
  - service-group cobra.Command carries the cmdmeta.domain annotation
  - leaf shortcuts inherit the domain via parent-chain walk

* feat(diagnostic): add unconditionally allowed command paths for introspection

* feat(plugins): add diagnostic command to inspect installed plugins and their contributions

* fix(cli): surface unknown_subcommand error instead of silent help fallback

When a user passed an unknown subcommand or shortcut (e.g. `lark-cli drive
+bogus`), cobra returned `flag.ErrHelp` for the non-runnable group command,
printed the parent help, and exited 0. AI agents couldn't distinguish a
typo from an intentional help request.

Install a tree-wide guard that attaches a RunE to every group command
without its own Run/RunE. The RunE forwards no-args invocations to help
(preserving prior behavior) and emits a structured unknown_subcommand
ExitError (exit 2) listing available subcommands when args are present.

* refactor(envelope): rename error.type pruning/strict_mode to command_denied

The envelope's `type` field was leaking implementation terms ("pruning",
"strict_mode") that describe enforcement mechanism rather than the user-
facing semantic. It also duplicated `detail.layer`, and forced consumers
to branch on two values for the same conceptual error ("a command was
denied by policy").

Collapse both into a single semantic type "command_denied". The
enforcement layer ("pruning" / "strict_mode") is preserved in
`detail.layer` so debugging and per-layer diagnostics still work.

* feat(platform): fail closed on unannotated/invalid risk when a Rule is active

The pruning engine used to treat any command without a risk annotation as
ALLOW even when a Rule with MaxRisk was set, and would silently skip the
MaxRisk comparison whenever the command's risk string was outside the
closed taxonomy. Both gaps let an unannotated or typo'd write command
slip past an "agent read-only" pruning rule.

Engine now denies before any other axis when a Rule is registered:
  - reason_code "risk_not_annotated" for commands with no risk
  - reason_code "risk_invalid"        for commands whose risk is outside
                                      the read | write | high-risk-write
                                      taxonomy (e.g. typo "wrtie")

Main-flow is preserved: a nil Rule still returns Allowed=true
unconditionally, so a CLI with no pruning plugin behaves identically to
before. ByUnknownRisk() is removed from the public surface since the
Unknown state is no longer reachable through risk-based selectors when
any Rule is active; safety-side widening composition is no longer needed.

* chore(config): hide diagnostic policy/plugins commands from --help

`config policy show`, `config policy validate`, and `config plugins show`
are local-introspection-only commands kept behind the pruning
diagnostic whitelist so operators can always inspect why a command was
denied. They do not need to surface in `--help` for AI agents and were
contributing to help noise.

Hide the `policy` and `plugins` parent groups and both `show` /
`validate` leaves. Commands remain callable by exact name and continue
to bypass user-layer pruning via diagnosticPaths.

* style: gofmt

* fix(platform): nil Selector honours None contract; reject multi-doc policy yaml

- selector.go: And/Or/Not now treat nil Selector as None() per godoc,
  preventing runtime panic when composed selectors are invoked.
- schema.go: Parse rejects multi-document YAML input so a stray '---'
  separator can't silently drop trailing policy constraints.

* chore: go mod tidy

* feat(extension/platform): plugin SDK with policy engine, hooks, and Builder

Introduces extension/platform — the in-process plugin SDK external
Go forks of lark-cli use to extend or restrict the command surface.
Plugins compile in via blank import; there is no dynamic loading
and no RPC isolation.

Public SDK (extension/platform):

  - Plugin interface (Name / Version / Capabilities / Install).
  - Registrar verbs: Observe, Wrap, On, Restrict.
  - Hook types: Observer (side-effect, panic-safe, fires Before/After
    RunE), Wrapper (middleware, may short-circuit via AbortError),
    LifecycleHandler (Startup / Shutdown), Selector with nil-safe
    And/Or/Not composition.
  - Risk / Identity are defined string types with closed taxonomies;
    ParseRisk / ParseIdentity convert raw strings with the
    absent-vs-invalid distinction the engine relies on.
  - Builder ergonomic constructor (NewPlugin().Observer().Wrap()
    ...MustBuild()) that enforces name/hookName grammar, hookName
    uniqueness, and the Restrict ↔ FailClosed pairing regardless of
    call order.
  - Invocation is a read-only interface; the framework's concrete
    invocation type lives in internal/hook so plugins cannot
    fabricate denial / strict-mode / identity state. Args() returns
    a defensive copy on every call so hook mutation cannot leak
    into the original RunE.
  - CommandDeniedError + AbortError carry structured fields for the
    closed `command_denied` / `hook` envelope contract.
  - ResetForTesting gated behind //go:build testing.
  - README + godoc examples (Observer / Wrapper / Restrict) + two
    runnable example forks (audit-observer, readonly-policy).

Host (internal/platform, internal/hook, internal/cmdpolicy):

  - InstallAll: staged plugin registration with atomic commit, panic
    isolation, FailOpen / FailClosed semantics, RequiredCLIVersion
    semver check, single-Restrict invariant, duplicate-plugin-name
    detection.
  - hook.Install wraps every runnable cmd.RunE with:
    Before observers (panic-safe) → denial guard → composed Wrap
    chain → original RunE → After observers (always fire, even on
    err). Denied commands physically bypass the Wrap chain so a
    plugin Wrapper cannot suppress or rewrite a denial; observers
    still see the attempt for audit.
  - Recover shim around plugin Wrappers converts panics (including
    the factory call) into a structured `hook` envelope with
    reason_code=panic; namespacing shim attributes AbortError to
    the namespaced hook name.
  - cmdpolicy (renamed from internal/pruning) is the user-layer
    command policy engine: walks the cobra tree, evaluates each
    runnable command against a Rule's four-axis filter (Allow /
    Deny / MaxRisk / Identities), produces parent-group aggregate
    denials, and installs denyStubs. Rule.AllowUnannotated opts out
    of the unannotated-deny gate for gradual adoption; risk_invalid
    typos always deny with an edit-distance "did you mean"
    suggestion.
  - Strict-mode stub in cmd/prune.go composes the shared
    detail.* / wrapped CommandDeniedError shape via cmdpolicy
    helpers (BuildDenialError / CommandDeniedFromDenial /
    DenialDetailMap), so command_denied envelopes from strict-mode
    and user-layer policy carry the same closed-enum fields
    (detail.layer / reason_code / policy_source). The historical
    short Message + independent Hint are preserved unchanged.
  - cmdpolicy/yaml: structural parsing of ~/.lark-cli/policy.yml
    with KnownFields strict mode, including allow_unannotated.
  - `config policy show` / `config policy validate` and the plugin
    inventory diagnostic surface the resolved Rule (allow,
    deny, max_risk, identities, allow_unannotated) and the hook
    contributions per plugin.

Envelope contract (docs/extension/reason-codes.md):

  - error.type is a closed set: command_denied, hook, plugin_install,
    plugin_conflict, plugin_lifecycle.
  - reason_code is a closed enum per error.type, dispatched on by
    external agents and CI integrations.
  - detail.layer = "policy" | "strict_mode" attributes the rejection.

Build / CI:

  - Makefile unit-test / vet / coverage and ci.yml fast-gate +
    unit-test + coverage now pass -tags testing so register_testing.go
    is visible; ./extension/... is in the package list so the SDK's
    own tests actually run.
  - fmt-check and examples-build Makefile targets.
  - bmatcuk/doublestar/v4 added as a direct dependency for `**` glob
    matching in Rule.Allow / Rule.Deny.

Author-facing material:

  - docs/extension/ (quickstart, plugin-author-guide, reason-codes)
    is provided in the working tree but kept out of git tracking
    per repo convention (.gitignore covers docs/).

Change-Id: I3b8ecc2923bd54c2dff19e5dce8a0855a6f9e703

* feat(extension/platform): plugin SDK with policy engine, hooks, and Builder

Introduces extension/platform — the in-process plugin SDK external
Go forks of lark-cli use to extend or restrict the command surface.
Plugins compile in via blank import; there is no dynamic loading
and no RPC isolation.

Public SDK (extension/platform):

  - Plugin interface (Name / Version / Capabilities / Install).
  - Registrar verbs: Observe, Wrap, On, Restrict.
  - Hook types: Observer (side-effect, panic-safe, fires Before/After
    RunE), Wrapper (middleware, may short-circuit via AbortError),
    LifecycleHandler (Startup / Shutdown), Selector with nil-safe
    And/Or/Not composition.
  - Risk / Identity are defined string types with closed taxonomies;
    ParseRisk / ParseIdentity convert raw strings with the
    absent-vs-invalid distinction the engine relies on.
  - Builder ergonomic constructor (NewPlugin().Observer().Wrap()
    ...MustBuild()) that enforces name/hookName grammar, hookName
    uniqueness, and the Restrict ↔ FailClosed pairing regardless of
    call order.
  - Invocation is a read-only interface; the framework's concrete
    invocation type lives in internal/hook so plugins cannot
    fabricate denial / strict-mode / identity state. Args() returns
    a defensive copy on every call so hook mutation cannot leak
    into the original RunE.
  - CommandDeniedError + AbortError carry structured fields for the
    closed `command_denied` / `hook` envelope contract.
  - ResetForTesting gated behind //go:build testing.
  - README + godoc examples (Observer / Wrapper / Restrict) + two
    runnable example forks (audit-observer, readonly-policy).

Host (internal/platform, internal/hook, internal/cmdpolicy):

  - InstallAll: staged plugin registration with atomic commit, panic
    isolation, FailOpen / FailClosed semantics, RequiredCLIVersion
    semver check, single-Restrict invariant, duplicate-plugin-name
    detection.
  - hook.Install wraps every runnable cmd.RunE with:
    Before observers (panic-safe) → denial guard → composed Wrap
    chain → original RunE → After observers (always fire, even on
    err). Denied commands physically bypass the Wrap chain so a
    plugin Wrapper cannot suppress or rewrite a denial; observers
    still see the attempt for audit.
  - Recover shim around plugin Wrappers converts panics (including
    the factory call) into a structured `hook` envelope with
    reason_code=panic; namespacing shim attributes AbortError to
    the namespaced hook name.
  - cmdpolicy (renamed from internal/pruning) is the user-layer
    command policy engine: walks the cobra tree, evaluates each
    runnable command against a Rule's four-axis filter (Allow /
    Deny / MaxRisk / Identities), produces parent-group aggregate
    denials, and installs denyStubs. Rule.AllowUnannotated opts out
    of the unannotated-deny gate for gradual adoption; risk_invalid
    typos always deny with an edit-distance "did you mean"
    suggestion.
  - Strict-mode stub in cmd/prune.go composes the shared
    detail.* / wrapped CommandDeniedError shape via cmdpolicy
    helpers (BuildDenialError / CommandDeniedFromDenial /
    DenialDetailMap), so command_denied envelopes from strict-mode
    and user-layer policy carry the same closed-enum fields
    (detail.layer / reason_code / policy_source). The historical
    short Message + independent Hint are preserved unchanged.
  - cmdpolicy/yaml: structural parsing of ~/.lark-cli/policy.yml
    with KnownFields strict mode, including allow_unannotated.
  - `config policy show` / `config policy validate` and the plugin
    inventory diagnostic surface the resolved Rule (allow,
    deny, max_risk, identities, allow_unannotated) and the hook
    contributions per plugin.

Envelope contract (docs/extension/reason-codes.md):

  - error.type is a closed set: command_denied, hook, plugin_install,
    plugin_conflict, plugin_lifecycle.
  - reason_code is a closed enum per error.type, dispatched on by
    external agents and CI integrations.
  - detail.layer = "policy" | "strict_mode" attributes the rejection.

Build / CI:

  - Makefile unit-test / vet / coverage and ci.yml fast-gate +
    unit-test + coverage now pass -tags testing so register_testing.go
    is visible; ./extension/... is in the package list so the SDK's
    own tests actually run.
  - fmt-check and examples-build Makefile targets.
  - bmatcuk/doublestar/v4 added as a direct dependency for `**` glob
    matching in Rule.Allow / Rule.Deny.

Author-facing material:

  - docs/extension/ (quickstart, plugin-author-guide, reason-codes)
    is provided in the working tree but kept out of git tracking
    per repo convention (.gitignore covers docs/).

Change-Id: I3b8ecc2923bd54c2dff19e5dce8a0855a6f9e703

* refactor(policy): remove validate command and update diagnostics

* fix(extension/platform): address PR review must-fix items

- cmdpolicy: skip AnnotationPureGroup commands in EvaluateAll,
  aggregateParents, and hasRunnableDescendant so user-layer policy
  no longer blocks `<group> --help` after the unknown-subcommand
  guard attaches RunE to every parent
- cmd/root: tag guarded parent groups with AnnotationPureGroup
- extension/platform: drop `//go:build testing` from register_testing.go
  so `go test ./...` works without an extra build tag
- extension/platform/README: inline reason_code reference, fix plugin
  lifecycle diagram order (init/Register precede RegisteredPlugins)
- cmd/platform_bootstrap: route userPolicyPath through
  core.GetBaseConfigDir so LARKSUITE_CLI_CONFIG_DIR is honoured
- cmdpolicy: add RedactHomeDir helper, fold base config dir and
  $HOME prefixes for config policy show + resolver errors
- internal/platform: reject unrecognised FailurePolicy values with
  invalid_capability instead of silently fail-open
- cmd/config: surface diagnostic policy/plugins commands in
  `config --help` Long text
- CHANGELOG: document command_denied error.type rename and
  unknown_subcommand exit-2 behavior change

* fix(extension/platform): address CodeRabbit review comments + CI gofmt

- hook/install: propagate wrapper-injected ctx to invokeOriginal so
  RunE/Run see context values added by upstream Wrappers
- hook/testing: SetStderrForTesting returns a restore func; tests now
  defer it via t.Cleanup to avoid cross-test sink leakage
- cmdpolicy/active: deep-copy ActivePolicy.Rule on SetActive/GetActive
  so callers can't mutate the stored global through shared slices
- platform/inventory: deep-copy Inventory + nested Plugins / HookEntry
  / RuleView slices on SetActiveInventory / GetActiveInventory
- platform/staging: Restrict clones the plugin-supplied Rule before
  retaining it so the plugin can't mutate it after Install returns
- platform/version: reject RequiredCLIVersion with more than three
  numeric components instead of silently truncating 1.2.3.4 to 1.2.3
- cmd/platform_bootstrap: clear cmdpolicy.SetActive on yaml resolver
  error so config policy show doesn't surface a stale rule
- cmd/platform_bootstrap_test: tmpHome pins LARKSUITE_CLI_CONFIG_DIR
  so host env can't bleed into the policy test fixtures
- cmdpolicy/apply: installDenyStub returns bool; Apply count no longer
  over-reports when strict-mode short-circuits the install
- cmdpolicy/engine: aggregateParents now returns the runnable hybrid's
  own denial status when all children are placeholder branches
- cmdpolicy/resolver_test: use t.TempDir()-rooted missing path instead
  of hardcoded /nonexistent for hermetic missing-file assertion
- cmd/config/plugins: empty-inventory branch emits total: 0 so the
  JSON schema stays stable across populated/empty cases
- cmd/platform_guards_test: select leaf by RunE != nil (not Runnable)
  so the test doesn't nil-deref on Run-only commands
- gofmt run on previously committed cmdpolicy/path*.go (CI fast-gate)

* fix(cmdpolicy): replace filepath.Abs with filepath.Clean for lint policy

The depguard / forbidigo rule blocks filepath.Abs in internal/ on the
grounds that it accesses the filesystem (Getwd) directly. Switch
RedactHomeDir + foldPrefix to operate on filepath.Clean strings; real
callers pass already-absolute paths (resolver builds yamlPath via
filepath.Join on the absolute config root), so the redaction outcome
is unchanged for production inputs. Relative inputs fall through to
the unchanged branch — filepath.Rel rejects the mixed-absoluteness
case with an error, which the foldPrefix helper already treats as
"not a hit".

* refactor(cmdpolicy): pure Resolve + drop path redaction & verbose comments

- Resolve becomes a pure function; I/O moves to LoadYAMLPolicy so
  precedence selection can be unit-tested without vfs mocks
- ActivePolicy drops YAMLPath; config policy show JSON loses yaml_path
  and yaml_shadowed (and the TOCTOU stat that surfaced them)
- RedactHomeDir and path_test.go removed: the home-dir folding was only
  earning its keep through the now-deleted yaml_path field
- cmd/build.go bootstrap block trimmed from 71 to 39 lines by cutting
  PR-rationale comments; one note kept for the fail-CLOSED-vs-fail-OPEN
  business rule
- cmd/config/config.go: parent Long no longer hard-codes hidden command
  hints, matching their Hidden:true intent

Change-Id: Icfbb818ce3ef523c63286bfbed34c49be08ed6a2

* refactor(platform): drop StrictMode/Identity from Invocation interface

These two accessors were documented in the public SDK as "After observers
always see ok=true" but the framework never plumbed values to them, so they
always returned ("", false). Zero internal/example/test callers; a plugin
author trusting the doc would silently get wrong behaviour.

Identity is also fundamentally unsuited for Before observers (per-command
identity resolves inside RunE via f.AuthFor, after Before fires). StrictMode
is a global value better placed on a Framework/Environment interface than
per-Invocation. Removing is non-breaking now (no callers); adding later is
non-breaking too.

Change-Id: Ice200543e9bca3bda759ad98a6e34a56df69e915

* fix(prune): preserve original metadata on strict-mode denial stubs

strictModeStubFrom built a fresh *cobra.Command from scratch, dropping
the original command's annotations (risk_level, lark:supportedIdentities,
cmdmeta.domain) and help text. cobraCommandView is a live proxy walking
parent annotations, so after the Remove+Add replacement, audit observers
firing on a strict-mode-denied command saw Cmd().Risk()=("",false) and
Cmd().Identities()=nil -- breaking the first-class use case for
audit/compliance plugins.

Copy child.Annotations into the stub (stamping the denial annotations on
top) and propagate Short/Long for help-text parity with
cmdpolicy/apply.go::installDenyStub, which preserves these by virtue of
mutating in place.

Regression test asserts risk_level / supportedIdentities / Short / Long
all survive replacement, alongside the denial annotations.

Change-Id: I19810a34575996344b63e839066888c154d69335

* chore(platform): align docs with implementation; fold home in yaml warnings

Followup cleanup to the previous three refactor commits, addressing review
fallout where public docs / examples / contract notes still pointed at
deleted symbols or unimplemented designs:

- cmd/build.go: Build() docstring now mentions the plugin install + Startup
  emit side effects; Shutdown only fires on Execute path
- extension/platform/doc.go, lifecycle.go, invocation.go: drop references
  to the deleted StrictMode/Identity methods, restore minimal Godoc on
  Cmd/Args/Started
- extension/platform/view.go, cmd/platform_bootstrap.go,
  internal/hook/install.go: rewrite "snapshot before pruning" promise to
  match the actual contract (live view + strict-mode stub metadata
  preservation)
- cmd/platform_guards_test.go: stubInvocation drops the two old methods
- cmd/platform_bootstrap.go: redactHome() last-mile folds $HOME -> ~ in
  warnPolicyError so an os.PathError carrying the absolute policy path
  does not leak the user's home dir to stderr / agent / CI logs
- examples/readonly-policy/README.md: drop yaml_path from the sample
  `config policy show` envelope (the field was removed in 52cbb92)

Change-Id: I2874cc2cf9225dfa44a9c07b2449149181b387cb

* chore(build): drop vestigial -tags testing from Makefile and CI

The `testing` build tag was introduced in 461e3c6 to gate
extension/platform/register_testing.go (ResetForTesting); PR review
0efee93 then dropped the //go:build testing directive from that file
so downstream `go test ./...` would work without the tag, but never
cleaned the matching tag references out of Makefile and ci.yml.

The result: 8 places passing -tags testing for a tag that nothing in
the repo actually gates, plus a Makefile comment that confidently
claims a gate exists. Net behaviour is identical to omitting the flag;
the only effect is misleading developers into believing there is a
test-only surface separation.

Drop the flag from vet / unit-test / lint / coverage / deadcode (head
+ base worktree) and remove the misleading comment. ResetForTesting's
public-API exposure was the conscious trade-off taken in 0efee93 and
is left untouched.

Change-Id: If0cd78c87d4aec2a2533419fe75b01aae6b165fd

* feat(cmdpolicy): enrich denial Reason with attempted value + rule constraint

The envelope reason for command_denied previously told the caller WHAT
axis failed but not the concrete values on each side, so an AI agent
reading the envelope could not tell which command identity / risk /
path was attempted vs. which the rule permits. The natural temptation
was then to recommend modifying the rule -- exactly the wrong nudge,
since policy exists to prevent the agent from rewriting its own limits.

Each Reason now carries both the attempted value and the rule's
constraint:

  identity_mismatch:
    "command supports identities [user]; rule allows [bot]"
  domain_not_allowed:
    "command path \"drive/+upload\" not in allow list [docs/** contact/**]"
  command_denylisted:
    "command path \"docs/+delete-doc\" matched deny pattern \"docs/+delete-*\""
  risk_too_high / write_not_allowed:
    "command risk \"high-risk-write\" exceeds rule max_risk \"write\""
  risk_not_annotated:
    "command has no risk_level annotation; rule denies unannotated commands"
    (drops the prescriptive "set allow_unannotated=true" hint -- that
     belongs in docs, not in the engine's denial path)

Adds firstMatch() helper so command_denylisted can name the specific
glob that fired; matchesAny() now wraps firstMatch.

Regression test pins the substring contract per reason_code so future
"comment cleanup" cannot silently strip the values out again.

Change-Id: I17c7cc9411f58e3e43ade5e1ce875f3b7fe3e5ea

* fix(cmdpolicy): gofmt engine_test.go

CI fast-gate flagged the test added in 2eb0c2b as unformatted. Local
make unit-test had it cached; should have run `make vet` (which runs
gofmt-equivalent check via fmt-check) before pushing. Trivial 3-line
indent fix.

Change-Id: I42297ae59f607b97b32e976c9ec1c9ec4ab7de21

* feat(cmd): annotate risk_level on all hand-written cobra commands

Without this, any non-empty user-layer policy.yml (default
allow_unannotated=false) denies these commands with reason_code
risk_not_annotated -- bricking auth login, config init, profile use
etc. on first contact with a policy.

cmdpolicy/engine evaluation now resolves to the intended axis (deny
list / allow list / max_risk / identities) instead of failing closed
on the unannotated gate. Policy authors can write `max_risk: write`
or `allow: [auth/** config/** ...]` to express real intent.

Classification:
  read              auth status/check/list/scopes, config show /
                    policy show / plugins show, doctor, completion,
                    schema, profile list, event list/status/schema/
                    consume
  write             auth login/logout, config init/bind/remove/
                    default-as/strict-mode, profile add/remove/
                    rename/use, event stop/_bus, api (raw transit)
  high-risk-write   update (replaces the CLI binary; failure can
                    leave the install broken)

Notes:
- api standalone is conservatively `write`; per-call risk is unknown
  at parse time (raw transit), so static gating only enforces the
  write-class minimum.
- event _bus is the hidden IPC daemon forked by consume; standalone
  invocation by users is not expected, but the annotation keeps
  policy evaluation consistent with the other event subcommands.
- The two diagnostic-allowlisted commands (config policy show /
  plugins show) still bypass the engine via diagnosticPaths; the
  read annotation is for consistency with surrounding leaves.

---------

Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-05-18 15:25:02 +08:00
zgz2048
ca6c6c3e29 fix: mark base field update high risk (#936) 2026-05-18 13:34:31 +08:00
fangshuyu-768
5778adfefa fix(drive): preserve parent token on nested overwrite (#908)
* fix(drive): preserve parent token on nested overwrite

Ensure drive +push overwrite requests for nested files keep parent_node aligned with the actual remote parent folder and report parent resolution failures explicitly.

* test(drive): cover nested overwrite push workflow

Add a live drive +push workflow case for overwriting a nested remote file so the PR parent-token fix is exercised against the real backend and verified to converge via +status.
2026-05-15 18:32:58 +08:00
songyoung77
7400226e34 feat(doc): add --width/--height flags to docs +media-insert (#832)
* feat(doc): add width/height params to buildBatchUpdateData

Extend buildBatchUpdateData signature with width and height int params.
When mediaType is "image" and either dimension is positive, the value is
included in the replace_image payload. Existing call sites pass 0, 0.

* feat(doc): add --width/--height flags with validation to docs +media-insert

* feat(doc): add aspect-ratio auto-calculation helpers

Add computeMissingDimension (pure ratio math) and detectImageDimensions
(header-only image.DecodeConfig) with PNG/JPEG/GIF blank-import decoders,
plus imageDimensions struct; drive with two new TDD tests.

* feat(doc): wire --width/--height into Execute with aspect-ratio calculation

* feat(doc): add best-effort dimension computation to DryRun

* docs: add --width/--height to docs +media-insert SKILL.md

* fix: add SafeInputPath validation to detectImageDimensionsFromPath

* fix: guard computeMissingDimension against division by zero and add rounding

* fix: add dimension upper bound, fix err variable reuse in Execute

* refactor: use early-return guard for zero native dimensions per review

* fix: add pixels unit to dimension validation error messages

* fix: surface dimension detection failures in dry-run to match Execute behavior

* fix: move dimension detection before upload to fail fast

* fix: restore withRollbackWarning on dimension detection errors in Execute

Dimension detection runs after the placeholder block is created (Step 2),
so failures must clean up the block to avoid leaving an empty placeholder
in the document.
2026-05-15 18:28:56 +08:00
河伯
f03138b9f0 feat(wiki): add +space-list / +node-list / +node-copy shortcuts (#392)
Introduce three new wiki shortcuts that wrap the corresponding raw APIs
with structured flags, formatted output, my_library alias handling, and
unified envelope shape, replacing the bare `lark-cli wiki spaces list`
/ `wiki nodes list` / `wiki nodes copy` flows for the common cases.

Shortcuts
- wiki +space-list (read, scopes: wiki:space:retrieve):
  lists wiki spaces. Default fetches a single page; --page-all walks
  every page capped by --page-limit (default 10, 0 = unlimited).
  Supports --page-size / --page-token / --format json|pretty|table|csv|ndjson.
  Output: {spaces, has_more, page_token} + Meta.Count. Pretty mode
  distinguishes "no spaces" from "empty page with has_more" and hints
  the caller to resume.

- wiki +node-list (read, scopes: wiki:node:retrieve):
  lists nodes in a space or under a parent. Same pagination + format
  story as +space-list. Accepts the my_library alias for --space-id
  with --as user (resolved via a shared resolveMyLibrarySpaceID helper
  extracted from +node-create); rejects my_library upfront for --as bot.

- wiki +node-copy (high-risk-write, scopes: wiki:node:copy):
  copies a node into a target space or parent. --target-space-id and
  --target-parent-node-token are mutually exclusive. Risk is marked
  high-risk-write to match the upstream API's danger: true flag, so the
  framework requires --yes. Source is preserved; subtree is copied.

Both list shortcuts pick the narrowest scope the upstream API accepts.
The framework's preflight (internal/auth/scope.go MissingScopes) does
exact-string scope matching, so declaring the broader wiki:wiki:readonly
form would wrongly reject tokens that carry only the per-API scope —
which the API itself accepts — and emit a misleading missing-scope hint.

Shared changes
- shortcuts/wiki/wiki_node_create.go: factor out resolveMyLibrarySpaceID
  so +node-list and +node-create share one my_library resolution path.
- shortcuts/wiki/shortcuts.go: register the three new shortcuts.
- skills/lark-wiki/SKILL.md and references/lark-wiki-{space,node-list,
  node-copy}.md: documentation for the new shortcuts.

Tooling
- scripts/check-doc-tokens.sh + Makefile gitleaks target:
  pre-commit check that scans skill reference docs for realistic-looking
  Lark token values without the _EXAMPLE_TOKEN placeholder convention,
  preventing gitleaks false positives.
- .gitleaks.toml: allowlist tuning.
- .gitignore: ignore .tmp/.

Tests
- shortcuts/wiki/wiki_list_copy_test.go: unit tests covering registry
  membership, declared-narrow-scope pinning, flag validation (page-size
  range, page-limit >= 0, target flag exclusivity, my_library + bot
  rejection), auto-pagination merging, --page-limit truncation
  surfacing next cursor, --page-token single-page mode, empty-slice
  serialisation, has_more hint pretty rendering, my_library user-path
  resolution, +node-copy copy-to-space / copy-to-parent + body shape,
  pretty rendering, and the high-risk-write --yes gate.
- tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go: live end-to-end
  workflow exercising the shortcut layer against a real tenant.
  Reuses an existing my_library node as a host so the test never adds
  to the top-layer quota; the copy is placed under the same host node.
- tests/cli_e2e/wiki/coverage.md: shortcut coverage entries added.

Minor cleanups
- skills/lark-doc/references/lark-doc-search.md and
  skills/lark-minutes/references/lark-minutes-search.md: replace
  realistic-looking example ou_ tokens with _EXAMPLE_ placeholders so
  scripts/check-doc-tokens.sh passes.

Change-Id: I9efb0557f477d369d7f26a09c1e154d4ab15b253

Co-authored-by: liujinkun <liujinkun@bytedance.com>
2026-05-15 14:38:18 +08:00
fangshuyu-768
52e0129078 feat(drive): add quick mode to status diff (#870) 2026-05-14 20:37:39 +08:00
SunPeiYang996
1c2d3d7679 docs: update lark-doc skill description (#890)
Change-Id: I77e2ae690b8976e37f69ae5d581fccc13917ec5e
2026-05-14 20:17:48 +08:00
wangweiming-01
0d20f88453 feat: support file-token overwrite and version output for drive +upload (#885)
Change-Id: I76c334578fc2fa5cfd2eedb4525b0d9d735f610e
2026-05-14 19:50:51 +08:00
shifengjuan-dev
a54a879330 feat(im): add --exclude-muted to +chat-search and new +chat-list (#820)
Add im +chat-list shortcut wrapping GET /open-apis/im/v1/chats (previously not exposed via lark-cli).
Add --exclude-muted to both +chat-search and +chat-list: client-side filter that calls POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status after each page and drops is_muted=true chats.
Introduce shortcuts/im/mute_filter.go with pure helpers and an orchestrator (MaybeApplyMuteFilter) shared by both shortcuts.

Change-Id: I22221ac5835667f58cbd40b34de75825d2445d1c
2026-05-14 17:47:34 +08:00
fangshuyu-768
f1aa7d8f42 feat(drive): add modified-time smart sync mode (#859) 2026-05-14 14:10:35 +08:00
shifengjuan-dev
5e0ac02f08 feat(im): add --chat-mode topic to +chat-create (#790)
Adds --chat-mode group|topic to lark-cli im +chat-create so users and AI agents can create 话题群 (topic chats) directly via the CLI. Without this, requests to create a topic chat silently fall back to a normal conversation group. Default remains group; chat_mode is now always emitted in the POST /open-apis/im/v1/chats request body.

Change-Id: I79385e2e8606f84e3f27de240d1b41037bf51261
2026-05-13 18:03:58 +08:00
xzcong0820
1180baac61 feat(mail): add unknown-flag fuzzy-match for lark-cli mail domain (#806)
Adds shortcuts/mail/flag_suggest.go (~120 LOC) implementing a cobra
FlagErrorFunc hook for the mail subcommand tree. On 'unknown flag: --X'
or 'unknown shorthand flag: "X" in -X', it collects flags from the
current command via cmd.Flags().VisitAll, runs bidirectional prefix
match + Levenshtein DP (threshold=max(1,len/3+1), cap 4), and returns
top-5 candidates inside the existing ErrorEnvelope JSON:

  error.type = "unknown_flag"
  error.detail.{unknown, command_path, candidates}
  error.detail.candidates[*] = {flag, shorthand, distance, reason}

Exit code stays 1 (ExitAPI), not ExitValidation - no breaking change for
CI/agent scripts that check non-zero exit. stderr switches from plain
'Error: unknown flag: --X' to JSON envelope, aligning with the existing
'errors = JSON envelope on stderr' convention; mail unknown-flag was the
last gap.

Scope is strictly the mail subcommand tree: shortcuts/register.go gains
a single 'if service == "mail" { mail.InstallOnMail(svc) }' branch
after the existing Mount loop. Other domains (calendar / im / api /
auth / ...) keep cobra's default FlagErrorFunc and unchanged plain-text
stderr behavior.

Covers:
- shortcuts/mail/flag_suggest.go      (new, ~120 LOC)
- shortcuts/mail/flag_suggest_test.go (new, 12 table-driven tests)
- shortcuts/register.go               (+3 lines after mail Mount loop)

No changes to cmd/root.go or internal/output/* - ErrDetail.Detail is
already interface{}, handleRootError already routes *ExitError via
WriteErrorEnvelope.
2026-05-12 14:28:09 +08:00
zhicong666-bytedance
db1a3fc0a6 feat(vc): add agent meeting join, leave, and events shortcuts (#824)
* feat(vc): agent join meeting basic shortcuts structure

Change-Id: Ic5d64067eb48670fa6636841cd00cbfa9b0bf3e7

* docs: add skill references for vc +meeting-join and +meeting-leave

* feat(vc): add meeting events shortcut

Add vc +meeting-events for bot meeting activity queries with page-all pagination support and tested pretty/json output.

* feat(vc): refine meeting events pagination and output

* test: add unit tests for vc +meeting-join and +meeting-leave shortcuts

* feat(vc): improve meeting events pretty timeline

* feat(vc): refine meeting events pretty output

* docs(skill): add vc meeting events shortcut guide

* docs(skill): clarify vc meeting events output guidance

* docs: clarify participant-snapshot vs meeting-events routing

* refactor: split lark-vc-agent from lark-vc

* docs: drop nonexistent workflow skill reference and fix identity

* docs: fix cross-links in lark-vc-agent references after split

* fix(vc): send meeting join password at top level

* docs: rewrite lark-vc-agent description in user-facing language

* docs: tighten lark-vc-agent description to descriptive neutral tone

* fix: use Chinese quotes in vc/vc-agent description YAML frontmatter

* docs: downgrade dry-run from mandatory to optional for vc-agent writes

* docs: clarify pretty vs json format choice by processing depth

* docs: systematic review of lark-vc-agent SKILL for clarity and precision

* feat(vc): print meeting event page token in pretty output

* docs(skill): refine vc agent meeting guidance

* revert: restore CRITICAL banner in lark-vc-agent to match repo convention

* docs: replace inaccurate no-replay warning with real social-cost risk

* docs: tighten meeting-join risk warning to single sentence

* docs: tighten vc-agent references - remove redundancy and fix vague wording

* Revert "docs: tighten vc-agent references - remove redundancy and fix vague wording"

This reverts commit 9845fc40622c65b0811da1c9ae4902434377f33e.

* docs(skill): refine vc meeting events paging guidance

* fix(vc): keep meeting event count aligned with events list

* docs(skill): tighten vc agent meeting events workflow

* refactor(vc): simplify meeting events pagination

* docs(skill): tighten vc agent meeting guidance

* docs(skill): require reading shared docs for meeting summaries

* chore(env): switch default feishu endpoints to pre

* fix(env): use feishu accounts host

* docs(vc): use explicit date in recording example

* revert(env): remove default ppe request header

* chore(env): switch default feishu endpoints to pre

* docs(skill): guide users to early-bird group on agent meeting gray miss

Teach the lark-vc-agent skill to recognize OAPI's new gray-miss signal for
the three agent meeting commands (`+meeting-join`, `+meeting-leave`,
`+meeting-events`) and route the user to the early-bird group instead of
treating it as a permission error.

When CLI stderr JSON returns `error.code=20017 / ErrNotInGray`, the agent
renders the fixed early-bird invite link
`https://go.larkoffice.com/join-chat/2f4nb0e1-fe00-4f67-bed7-25beaf533fbd`.
The user manual is intentionally not surfaced yet.

Scope-related errors still follow the existing `auth login --scope` flow
with no early-bird copy mixed in. lark-shared and other skills are not
touched, so the guidance stays scoped to the agent meeting commands only.

* chore(env): switch endpoints to boe for agent meeting gray testing

* chore(vc-agent): update gray guide and boe endpoints

* docs(vc-agent): refine gray guidance flow

* docs(vc-agent): centralize gray guidance

* fix(ci): stabilize vc output and skill frontmatter

* fix(vc): address review feedback

---------

Co-authored-by: zhaolei.vc <zhaolei.vc@bytedance.com>
Co-authored-by: renaocheng <renaocheng@bytedance.com>
2026-05-11 21:32:06 +08:00
fangshuyu-768
4ba39ef392 fix(drive): handle duplicate remote sync paths (#803) 2026-05-11 17:51:23 +08:00
SunPeiYang996
0ed63b02e4 chore(doc): inject docs scene into v2 requests (#808)
Change-Id: I4f23880e24164c8b229a5403942bfa1b7ddb0ce6
2026-05-11 14:35:00 +08:00
seemslike
16f1a0f320 feat: add flag shortcuts for im (#770)
Add IM flag shortcut commands to lark-cli, enabling users to create, list, and cancel bookmarks on messages and threads via +flag-create, +flag-list, and +flag-cancel.

Change-Id: I8f87f0eadf83fb59b024a3b9fe67b23d363abe0a
2026-05-11 11:32:06 +08:00
zgz2048
a81d07ca4f fix: clean base error detail output (#783) 2026-05-08 18:13:44 +08:00
sammi-bytedance
e754b3bc1b feat(im): add message_app_link to IM message outputs (#668)
- Assemble applinks via net/url to ensure proper encoding
- Normalize message position values across more numeric types
- Avoid leaking null message_app_link; assemble when missing
- Update unit tests to assert URL semantics and cover edge cases

Change-Id: Ic473cb563c8a648c4f6677c32b25b9f371a0f84e
2026-05-08 16:06:48 +08:00
syh-cpdsss
90757887b2 whiteboard-update as "write" risk (#775)
Change-Id: Iacc4d349b44337813392d75f4f0ec67718074efc
2026-05-07 22:53:37 +08:00
Zhang-986
b65147f208 fix: migrate task shortcut errors from bare fmt.Errorf to structured output.Errorf/ErrValidation (#740) 2026-05-06 21:45:37 +08:00
wittam-01
d317493e49 fix: add url to markdown +create output (#753)
Change-Id: I4fa870415bbad76f721f8aa170180e83fd20281b
2026-05-06 16:03:33 +08:00
zgz2048
a8f078478e docs: refine field update conversion guidance (#748)
* docs: refine field update conversion guidance

* docs: refine field update conversion rules

* docs: adjust field update conversion allowlist
2026-05-06 15:32:38 +08:00
bytedance-zxy
06275415b1 feat(task): add upload task attachment shortcut (#736)
* feat(task): add upload task attachment shortcut

Change-Id: I668bf3d856baa6e35ed982a33c4bf4d03b924f4b

* feat(task): update SKILL.md adding resource_type description

Change-Id: I3ef1aba33ee22e8b03e6f59bc2fb64f55a742270
2026-05-06 14:36:41 +08:00
zgz2048
b4c9c09de0 feat(base): support batch record get and delete (#630)
* feat(base): support batch record get and delete

* fix(base): address batch record PR feedback

* docs(base): refine record skill routing

* refactor(base): use batch record get and delete only

* refactor(base): share record selection normalization

* docs(base): clarify record get field projection help
2026-05-06 14:13:22 +08:00
caojie0621
7fb71c6947 feat(sheets): add sheet management shortcuts (#722)
* feat(sheets): add sheet management shortcuts

- add +create-sheet, +copy-sheet, +delete-sheet, and +update-sheet
- cover request-shape dry-run and sheet workflow tests
- document new sheet management shortcuts in lark-sheets skill

* docs(sheets): consolidate lark-sheets reference docs
2026-05-01 15:49:24 +08:00
河伯
020aeb87ad feat(drive): pre-flight 10000-rune total cap for +add-comment reply_elements (#605)
* feat(drive): pre-flight per-text-element byte limit for +add-comment

The open-platform comment API returns an opaque [1069302] Invalid or
missing parameters whenever a single reply_elements[i] text exceeds
its implicit byte budget. The error does not name which element failed
or that length is the cause, so callers resort to binary-search
debugging.

Empirically: Chinese text up to ~80 chars (~240 bytes) lands; ~130
chars (~390 bytes) fails. Set the pre-flight limit to 300 bytes which
sits safely inside the known-good zone.

- parseCommentReplyElements now rejects any text element whose UTF-8
  byte length exceeds 300, with an ExitError naming the element index
  (#N, 1-based) and both the rune and byte counts, plus an ErrWithHint
  recommending the correct remediation (split into multiple text
  elements — the comment UI renders them as one contiguous comment).
- The previous 1000-rune check is removed: it was too lenient (a
  Chinese text under that cap would still fail server-side).
- skills/lark-drive/references/lark-drive-add-comment.md documents
  the per-element limit and the correct split pattern so agents
  avoid constructing oversized single elements upstream.

Addresses Case 12 in the 踩坑列表 doc.

* fix(drive): correct +add-comment hint to match actual escape coverage

`escapeCommentText` only expands `<` and `>` (each → 4 bytes via
`&lt;` / `&gt;`); `&` is intentionally left as-is. Both the over-limit
hint and the inline comment in `parseCommentReplyElements` previously
claimed `&` was also escaped, with a "4-5 bytes each" range that
implicitly assumed `&amp;` (5 bytes) — a string of 300 `&` chars
would actually fit in the budget, but a user reading the hint would
think otherwise and pre-emptively split it.

Code:
- Hint string ends with `Note: '<' and '>' are HTML-escaped and
  counted in their escaped form (4 bytes each).` (was: included `&`
  and "4-5 bytes")
- Inline comment above the budget check now matches:
  `escapeCommentText only expands '<' and '>' (each becomes 4 bytes:
  &lt; / &gt;); '&' is intentionally left as-is.`

Tests (regression):
- New `300 ampersands accepted (escapeCommentText leaves '&' as-is)`
  subtest pins that 300 `&` chars stay within budget. Without the fix
  this also passed (function was always correct), but the hint was
  lying — the test pins the budget contract loud and clear.
- New `TestParseCommentReplyElementsHintMatchesEscape` asserts the
  hint string itself: must mention `'<' and '>'` / `4 bytes`, must NOT
  mention `'&'` / `&amp;` / `4-5 bytes`. Catches a future drift if
  `escapeCommentText` is changed without updating the hint, or
  vice-versa.

The skill md (`skills/lark-drive/references/lark-drive-add-comment.md`)
already had the right wording (`每个 < 或 > 占 4 字节`), so it was the
in-Go strings that drifted; this commit aligns code with doc.

* fix(drive): rewrite +add-comment length cap to match real server behavior

The original PR set a 300-byte per-element pre-flight check, justified
by the empirical pattern "~80 Chinese chars succeeds, ~130 fails". A
fresh round of probing the live `/open-apis/drive/v1/files/{token}/
new_comments` endpoint with a real docx shows that pattern does not
reproduce, and the actual contract is very different:

  - 10000 ASCII / 10000 Chinese / 10000 '<' (escaped to 40000 bytes)
    in a single text element: all OK
  - 10001 of any of the above in a single text element: [1069302]
  - 5000 + 5000 across two text elements (total 10000): OK
  - 5000 + 5001 across two text elements (total 10001): [1069302]
  - 4000 + 4000 + 4000 across three (total 12000): [1069302]

Two consequences:

1. The cap is *10000 runes total across all reply_elements text*, not
   300 bytes per element. The old check rejected legitimate input
   anywhere from ~100 to 10000 Chinese chars (≈100x too aggressive).

2. The hint that recommended "split the content across multiple
   {\"type\":\"text\",\"text\":\"...\"} elements" was actively wrong —
   splitting doesn't bypass a total cap. A user told to split a
   10001-char message into 5000+5001 hits the same opaque [1069302].

This commit:

- Replaces `maxCommentTextElementBytes = 300` with
  `maxCommentTotalRunes = 10000`. The constant's doc comment records
  the probe matrix above so future maintainers know how it was
  derived.
- Switches the measurement from `len(escapeCommentText(input.Text))`
  to `utf8.RuneCountInString(input.Text)`. Server counts raw runes;
  byte width and post-escape form are irrelevant. The escape itself
  still happens — `<` and `>` still get rendered literally — but it
  no longer participates in the length check.
- Tracks a running `totalRunes` across the whole reply_elements array
  and bails at the first element that pushes the cumulative total
  over the 10000-rune budget, with index reporting that points at the
  offending element.
- Rewrites the over-cap hint to (a) name the actual 10000-rune budget,
  (b) explicitly say splitting does NOT help, (c) drop the wrong
  "comment UI still renders them as one contiguous comment" framing
  that implied splitting was a workaround.
- Adds a `TestParseCommentReplyElementsHintForbidsSplitAdvice`
  watchdog that fails if any future drift puts the discredited split
  advice back into the hint.

Tests: 11 cases on TestParseCommentReplyElementsTextLength covering
single-element boundary (ASCII / Chinese / angle brackets at exactly
10000 and at 10001), multi-element total cap (5000+5000 OK, 5000+5001
rejected with index pointing at element #2), early-element-overshoot
indexing (first element at 10001 reports index #1, not the trailing
element), and mention_user not double-counting toward the cap.

Skill md updated: removes the 300-byte / "split into multiple
elements" advice; documents the 10000-rune total cap with a note that
the schema currently advertises 1-1000 chars and is out of date,
plus a procedure for re-probing if the server-side limit ever moves.

Manual API verification: rebuilt binary and posted comments at
boundary lengths — all OK cases (100 / 5000 / 10000 chars, 5000+5000
split) accepted by server; over-cap cases (10001 / 10100 single, and
5000+5001 split) rejected by the new pre-flight before reaching the
network.

---------

Co-authored-by: fangshuyu <fangshuyu@bytedance.com>
2026-04-30 18:52:44 +08:00
河伯
cfd89e0e28 feat(doc): warn when callout uses type= without background-color (#467)
* feat(doc): expand callout type= shorthand into background-color and border-color

When users write <callout type="warning" emoji="📝"> without an explicit
background-color, the Feishu doc renders the block with no color. This
commit adds fixCalloutType() which maps the semantic type= attribute to
the corresponding background-color/border-color pair accepted by create-doc.

- warning → light-yellow/yellow
- info/note → light-blue/blue
- tip/success/check → light-green/green
- error/danger → light-red/red
- caution → light-orange/orange
- important → light-purple/purple

Explicit background-color or border-color attributes are always preserved.
The fix is applied via prepareMarkdownForCreate() in both +create and
+update paths, and also inside fixExportedMarkdown() for round-trip fidelity.

* refactor(doc): replace silent callout type→color injection with hint output

Per reviewer feedback (SunPeiYang996), silently rewriting user Markdown is
the wrong layer for this adaptation. The type→color mapping is not part of
the Feishu spec, and covert transforms make debugging harder.

Replace fixCalloutType() (which rewrote the Markdown) with WarnCalloutType()
which leaves the Markdown unchanged and instead writes a hint line to stderr
for each callout tag that has type= but no background-color, telling the user
the recommended explicit attributes to add:

  hint: callout type="warning" has no background-color; consider: background-color="light-yellow" border-color="yellow"

Also fixes CodeRabbit feedback: the type= regex now accepts both single-quoted
and double-quoted attribute values (type='warning' and type="warning").

* fix(doc): harden background-color detection in WarnCalloutType

CodeRabbit flagged that the previous strings.Contains(attrs,
"background-color=") check missed forms like 'background-color =
"light-red"' with whitespace around the equals sign. Replace with a
regex that tolerates optional whitespace, and add a regression test.

* fix(doc): close real review gaps left over after rebase

PR #467's review thread had three substantive comments
(`fangshuyu-768`, 2026-04-21) that the prior reply messages claimed
were fixed in commit 7d4b556 — but that commit no longer exists on the
branch (lost in a rebase / squash), and the head still ships the
original buggy code. This commit makes the fixes real.

Three behavior fixes in shortcuts/doc/markdown_fix.go:

1. (#5) Tighten the type= and background-color= regex anchors. \b sits
   at any word/non-word boundary, and `-` is a non-word char, so
   `\btype=` also matched the suffix of `data-type=` — a tag like
   `<callout data-type="warning">` would emit a bogus light-yellow
   hint. Switched both regexes to `(?:^|\s)…` so a real attribute
   separator is required. The same anchor on background-color closes
   the symmetric case where a `data-background-color=` attribute
   would silently suppress the real hint.

2. (#4) WarnCalloutType is now a fence-aware line walker. Previously
   the regex ran over the entire markdown body, so a callout sample
   inside a documentation code fence (```markdown … ```) would
   generate a phantom stderr hint every time the docs mentioned the
   feature. The walker tracks fence state via the existing
   codeFenceOpenMarker / isCodeFenceClose helpers from
   docs_update_check.go, which handle both backtick and tilde fences
   per CommonMark §4.5.

3. (#3) Drop the ReplaceAllStringFunc-as-iterator pattern. The
   previous code routed callout iteration through a rewrite primitive
   whose rebuilt-string return value was discarded, then ran the same
   regex a second time inside the callback to recover the capture
   groups. New scanCalloutTagsForWarning helper uses
   FindAllStringSubmatch — one pass, no thrown-away allocation,
   intent matches the surface (read-only scan, not a mutator).

Tests: 5 new TestWarnCalloutType subtests pin each contract:

- data-type attribute does not trigger hint (#5)
- data-background-color does not suppress hint (#5, symmetric)
- callout inside backtick fence emits no hint (#4)
- callout inside tilde fence emits no hint (#4)
- callout after fence close still emits hint (#4, fence-state reset)

All 14 TestWarnCalloutType cases pass; go vet / golangci-lint
--new-from-rev=origin/main both clean.
2026-04-30 17:51:08 +08:00
zhouyue-bytedance
ac4c34f2ad feat: support file-name for drive export (#685)
* feat: support file-name for drive export

* test: cover drive export file-name metadata
2026-04-30 17:30:23 +08:00
zgz2048
3ed691b25c feat(base): add markdown output for record reads (#726)
* feat(base): add record read SOP guidance

1. Add a unified lark-base record read SOP for get/search/list routing, field projection, temporary view querying, pagination, matrix result binding, and link field reads.
2. Inline command-focused parameter guidance into +record-get, +record-search, and +record-list help, including examples, JSON shape, view scope, projection, and limit constraints.
3. Preserve base shortcut flag order in help output and add tests covering record read help guidance.
4. Remove the single-method record read skill references in favor of the unified SOP.

* test(base): remove stale record list fixture

* fix(base): scan record markdown output

* fix(base): fallback record markdown output

* fix(base): unify base token wording in shortcuts and skills
2026-04-30 17:09:17 +08:00
fangshuyu-768
30ad38d4b6 feat(drive): add +pull shortcut for one-way Drive → local mirror (#696)
* feat(drive): add +pull shortcut to mirror a Drive folder onto local

Adds `drive +pull`, a one-way Drive → local mirror command. It
recursively lists --folder-token, downloads each type=file entry
into --local-dir at the matching relative path, and optionally
deletes local files absent from the remote (mirror semantics).

Implementation notes:

- Listing recurses through subfolders with the standard 200-page
  pagination loop. Online docs (docx, sheet, bitable, mindnote,
  slides) and shortcuts are skipped since there is no equivalent
  local binary to write back. Folder tree is reproduced under
  --local-dir, with parent directories auto-created by FileIO.Save.

- Per-file --if-exists=overwrite (default) | skip controls how
  pre-existing local files are treated; the framework's enum guard
  rejects any other value.

- --delete-local is the only destructive flag and is bound to --yes
  in Validate: --delete-local without --yes is rejected upfront so
  no listing or download even runs. --delete-local --yes performs
  downloads first, then walks --local-dir and removes regular files
  not present in the remote map. This matches the spec doc's
  "high-risk-write" intent for --delete-local without making the
  default pull path require confirmation.

- --local-dir is funneled through validate.SafeLocalFlagPath so
  errors reference --local-dir instead of the framework default
  --file. FileIO().Stat then enforces existence and IsDir.

- Scopes: drive:drive.metadata:readonly + drive:file:download. The
  broader drive:drive is disabled by enterprise policy in some
  tenants.

- Listing helper (drivePullListRemote) is duplicated locally rather
  than reused from drive_status.go because that change is still in
  open PR #692; once it merges, both can be lifted into a shared
  drive package helper. TODO marker is left in the code.

Tests cover six unit scenarios (happy-path with nested subfolder +
docx skipping, --if-exists=skip, --delete-local rejection without
--yes, --delete-local --yes deletes orphans, absolute-path
rejection, bad enum) and four E2E dry-run scenarios (request shape,
absolute path rejection, --delete-local --yes guard, missing
required flag).

* docs(skills): document drive +pull in lark-drive skill

Adds references/lark-drive-pull.md covering parameters, output schema
(summary + per-item action breakdown), the type=file scoping rule,
the --if-exists policy matrix, and the --delete-local + --yes safety
contract. Calls out the network-traffic caveat (pull is full-download,
unlike +status which only fetches when both sides have the file) and
the cwd boundary on --local-dir.

Wires +pull into the Shortcuts table in SKILL.md.

* fix(drive): walk +pull on canonical absolute root to close symlink/.. escape

Same root cause as the +status fix: --local-dir was validated through
SafeLocalFlagPath but the walk used the user-supplied raw string.
SafeLocalFlagPath returns the original value (the canonical form is
discarded), and SafeInputPath itself relies on filepath.Clean for
normalization, which shrinks "link/.." to "." purely as string
manipulation. The kernel then resolves "link/.." through the symlink
target's parent at walk time, putting the traversal outside cwd.

For +pull the bug is more dangerous than for +status because it
travels through --delete-local --yes — a raw walk would let the
delete pass land on files outside cwd.

Fix:
- In Execute, resolve --local-dir via validate.SafeInputPath to get a
  canonical absolute path, and resolve "." the same way for cwd.
- Convert the resolved root back to a cwd-relative form
  (filepath.Rel) for download targets so FileIO.Save's existing
  SafeOutputPath check (which rejects absolute paths) still applies.
- For --delete-local, walk the canonical absolute root, then delete
  via the absolute path. Both values come from the validated
  safeRoot, so kernel path resolution cannot redirect a delete to a
  file outside the canonical subtree.
- drivePullWalkLocal now returns absolute paths instead of rel paths;
  the caller computes the rel_path via filepath.Rel against safeRoot
  for output / remote-set membership checks.

Adds TestDrivePullDeleteLocalDoesNotEscapeViaSymlinkParentRef as a
regression: it stages an "escape" sibling directory containing a
sentinel file, adds a "link" symlink in cwd pointing into it, and
runs +pull --delete-local --yes against an empty remote with
--local-dir "link/..". The sentinel must survive (proving --delete
did not escape) and the in-cwd file must be removed (proving the
walk did run).

* test(drive): pin walker / download behavior on +pull symlink corner cases

Adds three regressions on top of the canonical-root walk fix:

- TestDrivePullSkipsSymlinkInsideRoot: a child symlink inside the
  validated root pointing to a sibling temp dir. Under
  --delete-local --yes with an empty remote, the sentinel inside the
  target must survive (walker did not follow the child symlink) and
  the in-cwd file must be deleted (walker did run).

- TestDrivePullSurvivesCircularSymlinkInsideRoot: a child symlink
  pointing at one of its ancestors. The walk must terminate so the
  test does not hang on the per-test timeout.

- TestDrivePullDownloadDoesNotEscapeViaSymlinkParentRef: pins the
  download half of the fix. With --local-dir "link/.." the canonical
  root resolves to cwd, so the remote file must land in cwd, not
  inside the symlink target's parent. The preexisting sentinel inside
  the escape directory must remain untouched.

* fix(drive): +pull --delete-local must not unlink local files shadowed by online docs

CodeRabbit (PR #696) flagged that the --delete-local pass treated any
local path missing from `remoteFiles` as orphaned, but `remoteFiles` only
records type=file entries. If Drive held a docx/sheet/shortcut at the
same rel_path as a local file, the local file would be unlinked even
though Drive still owned that path.

drivePullListRemote now returns two views:

  - files:    rel_path -> file_token, type=file only (download/skip set)
  - allPaths: every entry's rel_path regardless of type

The download loop continues to consume `files`; the --delete-local pass
consults `allPaths`, so an online-doc shadow of a local filename keeps
the local file safe.

Also routes the local walk and the delete through the vfs abstraction
(vfs.ReadDir + vfs.Remove) instead of filepath.WalkDir + os.Remove.
This drops the //nolint:forbidigo justifications and lines up with how
internal/keychain and internal/registry already do filesystem I/O. The
recursive vfs.ReadDir walker preserves the same "do not follow child
symlinks" semantics that filepath.WalkDir gave us, so the canonical-root
escape protections in 240b772 stay intact.

Adds TestDrivePullDeleteLocalPreservesLocalFileShadowedByOnlineDoc as a
direct regression: Drive serves keep.txt (file) plus notes.docx (docx),
local has both keep.txt and a hand-edited notes.docx; --delete-local
--yes must download keep.txt, leave notes.docx untouched, and report
deleted_local=0.

* fix(drive): count +pull delete failures in summary.failed

CodeRabbit (PR #696) flagged that both delete_failed branches in the
--delete-local pass appended an item but left the `failed` counter at
zero, so the JSON summary could legitimately report `"failed": 0` after
a partially-failed mirror. Increment failed in both branches (the
filepath.Rel error path and the vfs.Remove error path) so summary.failed
reflects every item flagged delete_failed in items[].

Adds TestDrivePullDeleteLocalCountsFailureInSummary, which forces
vfs.Remove to fail by chmod-ing the local dir 0o555 right before the
run and restoring 0o755 in t.Cleanup so t.TempDir teardown still works.

* fix(drive): swap +pull walk/remove back to filepath/os to satisfy depguard

The previous fix-up commits used vfs.ReadDir + vfs.Remove inside the
+pull shortcut, which depguard's "shortcuts-no-vfs" rule rejects:
shortcuts cannot import internal/vfs directly. CI lint failed on the
import line.

Restore the same pattern used in drive_status.go and the prior +pull
walker:

- filepath.WalkDir to enumerate files under the canonical absolute
  root, gated by //nolint:forbidigo with a comment explaining why.
- os.Remove for the actual delete, also gated by //nolint:forbidigo.

The canonical-root safety still holds: validate.SafeInputPath bounds
the walk root inside cwd before WalkDir runs, and WalkDir's default
"do not follow child symlinks" policy is preserved. The two earlier
fixes (drivePullListRemote returning allPaths so online-doc shadows
do not look orphaned, and incrementing failed on delete_failed) stay
in place.

`go test ./shortcuts/drive/...` and `golangci-lint run
--new-from-rev=origin/main` are both clean.

* fix(drive): record remote folder rel_path in +pull allPaths

Follow-up to 45fe4e3. The folder branch in drivePullListRemote merged
descendant rel_paths into allPaths but never recorded the folder's own
rel_path, so a local regular file with the same name as a remote
folder still looked orphaned and got unlinked under --delete-local.

Adds the missing allPaths[rel] for the folder case and a regression:
TestDrivePullDeleteLocalPreservesLocalFileShadowedByRemoteFolder
stages a Drive containing a folder named shadow alongside a
downloadable file, with the local side holding a regular file named
shadow; --delete-local --yes must download keep.txt and leave the
shadow file untouched.

* fix(drive): +pull pagination + dir/file conflict + skill doc symlink claim

Codex review on PR #696 surfaced three issues; addressed in one go:

1. drivePullListRemote only honored next_page_token. The shared
   common.PaginationMeta helper accepts both page_token and
   next_page_token; switched +pull over so a backend reply using
   page_token no longer makes the lister stop at page 1 (which would
   silently drop later remote files from both download and
   --delete-local).

2. --if-exists=skip swallowed mirror conflicts. The skip/overwrite
   branch only checked Stat success, so a local directory shadowing a
   remote regular file was reported as action=skipped. Now Stat's
   IsDir() is checked first; the conflict surfaces as action=failed
   with a message naming the directory, under both --if-exists=skip
   and --if-exists=overwrite, and increments summary.failed.

3. Skill doc told callers to soft-link the target into cwd if they
   wanted to pull from outside cwd. That is wrong: SafeInputPath
   evaluates symlinks before the cwd check, so a symlink pointing
   out-of-tree is rejected. Replaced the bogus shortcut with the
   actually viable options (switch the agent working directory,
   physically move/copy the target, or skip the comparison).

Two new regressions:

- TestDrivePullSurfacesDirectoryFileMirrorConflict — table test over
  both policies asserting failed=1, no skipped, action=failed, plus
  the 'is a directory' hint in the error message.

- TestDrivePullPaginationHandlesPageTokenField — first page returns
  page_token (not next_page_token) with has_more=true; asserts both
  pages are fetched and both files land on disk.

* fix(drive): +pull exits non-zero on item failures; gate --delete-local

Two PR-696 review fixes:

- Item-level failures (download error, dir/file conflict, delete error)
  now surface as a structured partial_failure ExitError instead of a
  success envelope with summary.failed > 0. Exit code becomes non-zero
  and error.detail still carries the {summary, items[]} payload, so
  AI / script callers can detect the failure via the exit code without
  reaching into the JSON body.

- A failed download pass now skips the --delete-local walk entirely.
  Previously +pull would continue removing local-only files even when
  the download phase had partially failed, leaving the mirror in a
  half-synced state (some Drive files missing locally AND some
  local-only files unlinked). Re-runs after fixing the download error
  recover cleanly.

Skill doc / shortcut description / flag desc updated to call the
operation a one-way file-level mirror, since --delete-local only
unlinks regular files and does not prune empty local directories left
behind by remote folder deletes (true directory-level mirroring is
explicitly out of scope).

Tests: existing dir/file-conflict and delete-failure cases now assert
the partial_failure ExitError shape; new test covers the
"download fails => --delete-local skipped" gating contract.

* refactor(drive): consolidate folder-listing helpers into listRemoteFolder

Closes the post-#692 / post-#709 TODO that lived in drive_pull.go (and
the matching note in drive_push.go): both #692 and #709 are now on main,
so the three near-identical recursive Drive folder listers can collapse
into one.

New shared helper in shortcuts/drive/list_remote.go:

  driveRemoteEntry { FileToken, Type, RelPath }
  listRemoteFolder(ctx, runtime, folderToken, relBase) -> map[rel]entry

Returns one entry per Drive item (every type), keyed by rel_path.
Subfolders are descended into and the folder's own entry is recorded so
callers can reason about "this rel_path is occupied by a folder"
without re-listing. Pagination via common.PaginationMeta is unchanged.

Each shortcut now derives its own per-shortcut view from the unified
listing:

  - drive_status.go: collapses to remoteFiles (Type=="file" -> token) for
    the content-hash diff.
  - drive_pull.go: derives remoteFiles (Type=="file") for the download
    set, plus remotePaths (every rel_path) as the --delete-local guard.
  - drive_push.go: derives remoteFiles (Type=="file") for upload /
    overwrite / orphan-delete, plus remoteFolders (Type=="folder") for
    the create_folder cache. drivePushRemoteEntry was a duplicate of
    driveRemoteEntry's first two fields and is dropped; the few call
    sites that read .FileToken keep working unchanged.

Per-shortcut copies removed:
  - drive_status.go: listRemoteForStatus, joinRelStatus,
    driveStatusListPageSize/FileType/FolderType
  - drive_pull.go: drivePullListRemote, drivePullJoinRel,
    drivePullListPageSize/FileType/FolderType
  - drive_push.go: drivePushListRemote, drivePushJoinRel,
    drivePushListPageSize/FileType/FolderType, drivePushRemoteEntry

drive_push_test.go's TestDrivePushHelpersRelPath is retargeted at the
shared joinRelDrive; the docstrings on the same-name-conflict tests
were tweaked to refer to "the remoteFiles view" instead of the
just-removed drivePushListRemote.

Net diff: +1 new file, -207 net lines across the four touched files.
All existing unit + e2e dry-run tests pass without behavioral change;
the rel_path / pagination / type-filter contracts each shortcut depends
on are preserved by construction.
2026-04-30 17:07:59 +08:00
wittam-01
f27b8fdf40 feat: add markdown shortcuts and skill docs (#704)
Change-Id: Iced88525deb10b014b755ec68bd9a8ae6a935143
2026-04-30 15:47:36 +08:00
liangshuo-1
c100ca049e feat(cmdutil): support @file for params and data (#724)
* feat(cmdutil): support @file for --params/--data (issue #705)

Inline JSON values for --params/--data are mangled by Windows
PowerShell 5's CommandLineToArgvW. Stdin (-) was the only escape
hatch but supports just one flag at a time.

Extend ResolveInput to accept @<path> (read JSON from a file) and
@@... (escape for a literal @-prefixed value), mirroring the
shortcuts framework's resolveInputFlags semantics. With this, both
--params and --data can be sourced from files in the same call,
sidestepping shell quoting on every platform.

- internal/cmdutil/resolve.go: add @path / @@ handling, trim file
  content like stdin does, error on empty path or empty file
- internal/cmdutil/resolve_test.go: cover file read, whitespace
  trim, missing file, empty path, empty content, @@ escape, plus
  ParseJSONMap / ParseOptionalBody integration through @file
- cmd/api/api.go, cmd/service/service.go: update --params/--data
  help text to mention @file

Change-Id: I366aa0f5783fbec6f05403f7f542505098a98c82

* refactor(cmdutil): route @file through fileio.FileIO abstraction

The first cut of @file support called os.ReadFile directly inside
ResolveInput, bypassing the codebase's fileio.FileIO abstraction
(SafeInputPath validation, pluggable provider). That diverged from
how every other file-reading path works: BuildFormdata for --file
uploads and the shortcuts framework's resolveInputFlags both go
through fileio.FileIO.Open with explicit fileio.ErrPathValidation
handling.

Re-route @file through the same path:

- ResolveInput, ParseJSONMap, ParseOptionalBody now take a
  fileio.FileIO; @path uses fileIO.Open which goes through
  SafeInputPath (control-char rejection, abs-path rejection,
  symlink-escape check) — same security posture as --file
- cmd/api and cmd/service callsites pass
  Factory.ResolveFileIO(ctx); the upload path now reuses the
  resolved fileIO instead of resolving twice
- Path-validation errors surface as
  `--params: invalid file path "...": ...` distinct from
  `--params: cannot read file "...": ...` for genuine I/O errors
- Nil fileIO with an @path returns a clear
  "file input (@path) is not available" error
- Tests use localfileio.LocalFileIO with TestChdir(t, dir),
  matching the existing fileupload_test.go pattern; absolute-path
  rejection and nil-fileIO are covered

This makes the feature behave identically under any FileIO
provider (including server mode) instead of being silently bound
to the local filesystem.

Change-Id: I878c4e8fb03f43f1f19afad75ec3af9cdab7a7f9

* refactor(cmdutil): share at-file input handling

Change-Id: I92a6eb6ea8fd02054bf8f4925cd81807449d5e51
2026-04-30 15:34:45 +08:00
fangshuyu-768
4d68e09537 feat(drive): add +push shortcut for one-way local → Drive mirror (#709)
* feat(drive): add +push shortcut for one-way local → Drive mirror

Mirrors a local directory onto a Drive folder: walks --local-dir,
recursively lists --folder-token, mirrors local subdirectory structure
(including empty dirs) onto Drive via create_folder, and for each
rel_path uploads new files, overwrites already-present files, or skips
them per --if-exists. With --delete-remote --yes, any Drive type=file
entry absent locally is removed; Lark native cloud docs (docx/sheet/
bitable/mindnote/slides) and shortcuts are never overwritten or deleted.

Overwrite hits POST /open-apis/drive/v1/files/upload_all with the
existing file_token in the form body and the response's `version` is
propagated to items[].version, mirroring the markdown +overwrite
contract. Files >20MB fall back to the 3-step
upload_prepare/upload_part/upload_finish path with a single shared fd
reused via io.NewSectionReader per block.

Output is a {summary, items[]} envelope; items[].action is one of
uploaded / overwritten / skipped / folder_created / deleted_remote /
failed / delete_failed.

--delete-remote is bound to --yes upfront in Validate, same pattern as
+pull's --delete-local: a stray flag never silently deletes anything.
Path safety reuses the canonical-root walk + SafeInputPath mechanics
from the sibling +status / +pull commands.

Scopes: drive:drive.metadata:readonly + drive:file:upload +
space:folder:create. space:document:delete is intentionally NOT in the
default set — the framework's pre-flight scope check would otherwise
block plain pushes and dry-runs for callers that haven't granted delete;
--delete-remote --yes relies on the runtime DELETE call to surface
missing_scope. The skill ref calls out the scope so users running
mirror sync can grant it upfront.

13 unit tests cover the upload/overwrite/skip/delete matrix, online-doc
protection, same-name conflict between local file and native cloud doc,
empty-directory mirroring, multipart, scope/path validation, and helper
correctness. 4 dry-run e2e tests pin the request shape.

* fix(drive +push): address review — failure semantics, default skip, scope pre-check, mirror wording

- Item-level failures now bump the exit code via output.ErrBare(ExitAPI)
  while keeping the structured items[] envelope on stdout. The
  --delete-remote phase is skipped entirely when any upload / overwrite /
  folder step fails, so a partial upload never proceeds to delete remote
  orphans (a half-synced state).
- Default --if-exists flipped from "overwrite" to the safer "skip": the
  upload_all overwrite-version protocol field is still rolling out, so
  the default no longer fails a first push against a pre-populated
  folder. Callers must opt into "overwrite" explicitly.
- --delete-remote --yes now triggers a conditional space:document:delete
  scope pre-check in Validate via the new RuntimeContext.EnsureScopes
  helper, so a missing grant fails the run before any upload — instead
  of after the upload phase, which would leave orphans uncleaned.
- Description, Tips and skill doc rewritten to call this a file-level
  mirror (not a directory mirror): the command does not remove
  remote-only directories or close gaps in directory structure that
  exists only on Drive.

Tests:
- new TestDrivePushDefaultsToSkipForExistingRemote pins the new default
- new TestDrivePushSkipsDeleteAfterUploadFailure pins the half-sync
  guard and the non-zero exit on item-level failure
- new TestDrivePushExitsZeroOnCleanRun pins the inverse
- existing tests that relied on the old overwrite default now opt in
  explicitly with --if-exists=overwrite
- TestDrivePushOverwriteWithoutVersionFails updated to assert
  *output.ExitError with Code=ExitAPI
- new TestDrive_PushDryRunAcceptsDeleteRemoteWithYes (e2e) symmetric to
  the existing reject-without-yes test, pinning that EnsureScopes is a
  silent no-op when the resolver has no scope metadata

* fix(drive +push): close remaining CodeRabbit comments

Three small follow-ups on the +push review thread that were still
open after the earlier failure-semantics / default-skip / scope
pre-check fix:

- drivePushUploadAll now extracts data.file_token before checking
  larkCode, and surfaces the returned token on the partial-success
  path (non-zero code + non-empty file_token). Without this, a backend
  response where bytes already landed but code != 0 would force the
  caller to fall back to entry.FileToken and silently lose the actual
  Drive token, defeating the overwrite-error token-stability handling
  in Execute.
- TestDrivePushOverwriteWithoutVersionFails switched from "tok_keep"
  to "tok_keep_new" in the upload_all stub and now asserts that the
  returned token (not entry.FileToken) lands in items[].file_token —
  pins the contract that a regression to the fallback branch would
  otherwise pass silently.
- New TestDrivePushOverwritePartialSuccessSurfacesReturnedToken pins
  the new partial-success branch end-to-end.
- drive_push_dryrun_test.go: tightened the three Validate / cobra
  rejections from `exit != 0` to exact codes — `exit == 2` for the
  two Validate-stage rejections (--local-dir absolute,
  --delete-remote without --yes), `exit == 1` for the cobra
  required-flag check (--folder-token missing). Locks in failure
  classification so a regression that misroutes the error layer
  doesn't slip through.
2026-04-30 15:00:44 +08:00
fangshuyu-768
a3bbe00ee0 feat(drive): add +status shortcut for content-hash diff (#692)
* feat(drive): add +status shortcut for content-hash diff

Adds `drive +status`, a read-only diff primitive that walks --local-dir,
recursively lists --folder-token, and reports four buckets — new_local,
new_remote, modified, unchanged — by SHA-256 content hash.

Implementation notes:

- Drive's list/metas APIs do not expose a content hash, so files
  present on both sides are downloaded via DoAPIStream and hashed in
  memory (sha256 + io.Copy, no disk write). Files only on one side are
  not fetched. The command stays Risk: "read".

- Only Drive entries with type=file participate. Online docs (docx,
  sheet, bitable, mindnote, slides) and shortcuts are skipped — there
  is no equivalent local binary to hash against.

- --local-dir is funneled through the framework's
  validate.SafeLocalFlagPath helper so that absolute paths and any ..
  that escapes cwd are rejected with --local-dir in the error message
  (rather than the internal default --file). FileIO().Stat() then
  enforces existence and the IsDir check.

- Local walk uses filepath.WalkDir behind a //nolint:forbidigo comment.
  The runtime FileIO interface has no walker today and shortcuts can't
  import internal/vfs; SafeInputPath has already bounded the walk root
  inside cwd, so the bare walk is acceptable until a runtime-level
  walker lands.

- Scopes: drive:drive.metadata:readonly (list folders) +
  drive:file:download (fetch files for hashing). The broader
  drive:drive scope is disabled by enterprise policy in some tenants;
  this narrower pair was verified end-to-end.

Tests cover the four-bucket categorization with a nested subfolder and
docx/shortcut filtering, plus validation errors for missing local-dir,
non-directory local-dir, and absolute-path local-dir.

* docs(skills): document drive +status in lark-drive skill

Adds references/lark-drive-status.md covering parameters, output
schema, the type=file scoping rule, and the network-traffic caveat
(hash is streamed in memory, but bytes still cross the wire).

Notes that --local-dir is bounded to cwd by the CLI's path validation,
and that when a user wants to compare a directory outside cwd the
agent should ask the user to relocate or to switch the agent's working
directory rather than `cd`-ing on its own.

Wires +status into the Shortcuts table in SKILL.md.

* test(drive): cover --folder-token validation and add +status dry-run E2E

Addresses two CodeRabbit review comments on PR #692:

- Adds TestDriveStatusRejectsEmptyFolderToken and
  TestDriveStatusRejectsMalformedFolderToken so the Validate-stage
  required-check and the ResourceName format guard for --folder-token
  are exercised, not just --local-dir.

- Adds tests/cli_e2e/drive/drive_status_dryrun_test.go which drives
  the real binary in dry-run mode and asserts:

  * the request shape (GET /open-apis/drive/v1/files with
    folder_token in the dry-run envelope), plus the description text,
  * --local-dir absolute paths are rejected by Validate (which still
    runs under --dry-run) with --local-dir surfaced in the message,
  * cobra's required-flag enforcement rejects a missing
    --folder-token before any custom validation.

* fix(drive): walk +status on canonical absolute root to close symlink/.. escape

Reported in PR review: --local-dir was validated through
SafeLocalFlagPath, but the actual walk used the user-supplied raw
string. SafeLocalFlagPath returns the original value (it only checks
the path through SafeInputPath and discards the canonical form), and
SafeInputPath itself relies on filepath.Clean for path normalization.
filepath.Clean shrinks "link/.." to "." purely as string manipulation,
so the validator sees a path inside cwd. The kernel, however, resolves
"link/.." through the symlink target's parent — which is outside cwd
and is what filepath.WalkDir actually traverses.

Fix: in Execute, resolve --local-dir via validate.SafeInputPath to
get the canonical absolute path (this one fully evaluates symlinks
across the entire path), and walk that path. Each absolute walk hit
is converted to a cwd-relative form via filepath.Rel against
validate.SafeInputPath(".") so FileIO.Open's existing SafeInputPath
guard (which rejects absolute paths) still applies.

Adds TestDriveStatusDoesNotEscapeViaSymlinkParentRef as a regression:
it stages an "escape" sibling directory containing a sentinel file,
adds a "link" symlink in cwd pointing into the escape directory, and
runs +status with --local-dir "link/..". Without this fix, the raw
walk visits the sentinel and leaks it into new_local; with the fix,
the walk stays inside the canonical cwd.

A standalone repro confirms the underlying behavior: raw
filepath.WalkDir("link/..", ...) traversed dozens of unrelated files
in the kernel-resolved parent directory; walking the canonical root
visits only the legitimate cwd contents.

* test(drive): pin walker behavior on child / circular symlinks for +status

Adds two corner-case regressions to back up the canonical-root walk fix:

- TestDriveStatusSkipsSymlinkInsideRoot: a child symlink under
  --local-dir that points to a sibling temp dir outside cwd. WalkDir's
  default policy must report it as a non-regular entry so the callback
  skips it, and the sentinel inside the target must not surface in
  new_local. This pins the contract our caller relies on (walk
  declines to follow child symlinks even when the canonical root
  resolves cleanly).

- TestDriveStatusSurvivesCircularSymlinkInsideRoot: a child symlink
  pointing back at one of its ancestors. The walk must terminate and
  surface the legitimate sibling file; if WalkDir ever followed the
  loop, the per-test timeout would catch it.

* fix(drive): close +status review gaps from Codex (pagination, doc, live E2E)

Three independent fixes flagged on PR #692:

1. Route the recursive Drive folder listing through common.PaginationMeta
   instead of reading next_page_token directly. The shared helper accepts
   both page_token and next_page_token, matching what okr/im already do
   and keeping +status safe against a backend field rename. Adds
   TestDriveStatusPaginatesRemoteListing, which serves a 2-page response
   where page 1 advertises the cursor as next_page_token and page 2 as
   page_token; either spelling alone would silently drop one page.

2. The skill doc previously suggested "or symlink the target into cwd"
   as a workaround for cwd-relative --local-dir. SafeInputPath calls
   filepath.EvalSymlinks before checking isUnderDir(canonicalCwd), so
   any symlink whose final target sits outside cwd still gets rejected
   as `unsafe file path`. Rewrite the section so agents stop steering
   users into a path that always errors out.

3. Add tests/cli_e2e/drive/drive_status_workflow_test.go — the live
   E2E that AGENTS.md requires for new shortcuts. Seeds a real Drive
   folder with three uploaded files (unchanged.txt, modified.txt,
   remote-only.txt), seeds a local tree with matching/diverging
   content plus a local-only.txt, runs +status, and asserts each of
   the four buckets contains exactly the file we expect with the
   right file_token. Cleanup of every uploaded file plus the parent
   folder is registered through the existing best-effort cleanup
   helpers. Coverage table bumped: drive +status moves to ✓ and the
   denominator goes from 28→29 to account for the new shortcut.

Codex also flagged the local-side filepath.WalkDir as a vfs-bypass.
Investigated: the depguard rule shortcuts-no-vfs explicitly forbids
shortcuts from importing internal/vfs (see commit c1b0bed on the
+pull branch where the same migration was rejected by CI). The
filepath.WalkDir + nolint:forbidigo pattern in walkLocalForStatus is
the lint-required convention until FileIO grows a walker, so leaving
it as-is.
2026-04-30 14:27:25 +08:00
calendar-assistant
0250054a90 feat(minutes): add media upload shortcut (#725)
Support minutes +upload to generate a minute from an uploaded media file token.

Change-Id: I59c0719a39541134e395a23262aea7f387105715

Co-authored-by: calendar-assistant <calendar-assistant@users.noreply.github.com>
2026-04-30 11:19:22 +08:00
SunPeiYang996
d7ee5b5769 feat: guide lark-doc v2 usage (#710)
## Summary
Add explicit guidance on the parent `docs` command so agents pick the right
lark-doc API version. Without this, agents that have an older lark-doc skill
installed can mistakenly mix v2 flags into a v1 flow.

## Changes
- Add `--api-version` help flag and a Tips section to `docs` so `lark docs --help`
  (and `--api-version v2`) explain when v2 should be used.
- Refresh the lark-doc skill references and `docs_fetch_v2` keyword flag
  description for clarity.
- Add `shortcuts/register_test.go` covering the new docs help wiring.

## Test Plan
- [x] Unit tests pass (`go test ./shortcuts/...`)
- [x] Manual local verification confirms the `lark docs --help` and
      `lark docs --help --api-version v2` commands work as expected

## Related Issues
- None

Change-Id: Id3b3196e6a069bb52f95a6fc679b8258313faf3d
2026-04-29 22:40:20 +08:00