From fec86699cf01840769cccad0ea6525e3beb33141 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Fri, 17 Jul 2026 07:05:56 -0400 Subject: [PATCH 01/12] fix(cfl): resolve --config split-brain resolution (#455) --- tools/cfl/internal/cmd/root/root.go | 54 +++++++--- tools/cfl/internal/cmd/root/root_test.go | 132 +++++++++++++++++++++++ tools/cfl/internal/config/config.go | 18 +++- tools/cfl/internal/config/config_test.go | 4 +- 4 files changed, 186 insertions(+), 22 deletions(-) diff --git a/tools/cfl/internal/cmd/root/root.go b/tools/cfl/internal/cmd/root/root.go index b64f0ed7..9700578b 100644 --- a/tools/cfl/internal/cmd/root/root.go +++ b/tools/cfl/internal/cmd/root/root.go @@ -23,6 +23,7 @@ import ( // Options contains global options for commands type Options struct { + ConfigPath string Output string NoColor bool Full bool @@ -35,7 +36,8 @@ type Options struct { testClient *api.Client // cachedConfig stores loaded config for reuse - cachedConfig *config.Config + cachedConfig *config.Config + tokenResolved bool } // View returns a configured View instance. @@ -75,21 +77,45 @@ func (o *Options) RenderStyle() present.Style { // If a test client is set and no config is cached, returns an empty config // (since tests inject their own client and typically don't need real config). func (o *Options) Config() (*config.Config, error) { - if o.cachedConfig != nil { + if o.cachedConfig != nil && o.tokenResolved { return o.cachedConfig, nil } // If test client is set, return empty config since tests inject their own client if o.testClient != nil { o.cachedConfig = &config.Config{} + o.tokenResolved = true return o.cachedConfig, nil } - cfg, err := config.LoadWithEnv(config.DefaultConfigPath()) + cfg, err := o.loadConfig() if err != nil { return nil, fmt.Errorf("loading config: %w (run 'cfl init' to configure)", err) } + if !o.tokenResolved { + if err := config.ResolveToken(cfg); err != nil { + return nil, fmt.Errorf("loading config: %w (run 'cfl init' to configure)", err) + } + } if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("invalid config: %w (run 'cfl init' to configure)", err) } + o.tokenResolved = true + return cfg, nil +} + +func (o *Options) loadConfig() (*config.Config, error) { + if o.cachedConfig != nil { + return o.cachedConfig, nil + } + path := o.ConfigPath + if path == "" { + path = config.DefaultConfigPath() + } + // Defer token resolution until after PersistentPreRunE wires the + // backend selected by this same config. + cfg, err := config.LoadWithEnv(path, false) + if err != nil { + return nil, err + } o.cachedConfig = cfg return cfg, nil } @@ -97,6 +123,7 @@ func (o *Options) Config() (*config.Config, error) { // SetConfig sets a test config (for testing only) func (o *Options) SetConfig(cfg *config.Config) { o.cachedConfig = cfg + o.tokenResolved = true } // APIClient creates a new API client from config @@ -149,12 +176,16 @@ Get started by running: cfl init`, if err := validateOutputFormat(opts.Output); err != nil { return err } - return wireBackendSelection(cmd) + cfg, err := opts.loadConfig() + if err != nil { + return err + } + return wireBackendSelection(cmd, cfg) }, } // Global flags - bound to opts struct - cmd.PersistentFlags().StringP("config", "c", "", "config file (default: ~/.config/cfl/config.yml)") + cmd.PersistentFlags().StringVarP(&opts.ConfigPath, "config", "c", config.DefaultConfigPath(), "config file") cmd.PersistentFlags().StringVarP(&opts.Output, "output", "o", "table", "output format: table, plain") cmd.PersistentFlags().BoolVar(&opts.NoColor, "no-color", false, "disable colored output") cmd.PersistentFlags().BoolVar(&opts.Full, "full", false, "show full inspection-oriented output (default: agent)") @@ -189,7 +220,7 @@ func validateOutputFormat(format string) error { // Best-effort config load: commands that don't need credentials (e.g., // `cfl completion`) must not fail just because config is missing or // malformed; commands that do need them handle their own load errors. -func wireBackendSelection(cmd *cobra.Command) error { +func wireBackendSelection(cmd *cobra.Command, cfg *config.Config) error { var flagValue string var flagSet bool if bf := cmd.Flag(cccredstore.BackendFlagName); bf != nil { @@ -197,17 +228,8 @@ func wireBackendSelection(cmd *cobra.Command) error { flagSet = bf.Changed } - var configBackend string - cfgPath, _ := cmd.Root().PersistentFlags().GetString("config") - if cfgPath == "" { - cfgPath = config.DefaultConfigPath() - } - if cfg, err := config.Load(cfgPath); err == nil && cfg != nil { - configBackend = cfg.Keyring.Backend - } - opts := &cccredstore.Options{} - if err := cccredstore.BindBackendFlag(opts, flagValue, flagSet, configBackend); err != nil { + if err := cccredstore.BindBackendFlag(opts, flagValue, flagSet, cfg.Keyring.Backend); err != nil { return fmt.Errorf("--%s: %w", cccredstore.BackendFlagName, err) } keyring.SetBackendSelection(opts.Backend, opts.ConfigBackend) diff --git a/tools/cfl/internal/cmd/root/root_test.go b/tools/cfl/internal/cmd/root/root_test.go index 89e0029c..9c44afbe 100644 --- a/tools/cfl/internal/cmd/root/root_test.go +++ b/tools/cfl/internal/cmd/root/root_test.go @@ -2,13 +2,23 @@ package root import ( "bytes" + "fmt" + "path/filepath" "testing" "github.com/open-cli-collective/atlassian-go/artifact" + "github.com/open-cli-collective/atlassian-go/auth" + sharedclient "github.com/open-cli-collective/atlassian-go/client" + "github.com/open-cli-collective/atlassian-go/credtest" + "github.com/open-cli-collective/atlassian-go/keyring" "github.com/open-cli-collective/atlassian-go/present" "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/atlassian-go/view" + cccredstore "github.com/open-cli-collective/cli-common/credstore" "github.com/spf13/cobra" + + "github.com/open-cli-collective/confluence-cli/api" + "github.com/open-cli-collective/confluence-cli/internal/config" ) func TestNewCmd(t *testing.T) { @@ -248,3 +258,125 @@ func TestOptions_RenderStyle_PlainOutput(t *testing.T) { t.Errorf("RenderStyle() = %v, want StyleHumanPlain", got) } } + +func TestConfigFlagIsAuthoritative(t *testing.T) { + tests := []struct { + name string + authMethod string + url string + email string + cloudID string + checkClient func(*testing.T, *api.Client, string) + }{ + { + name: "basic", + authMethod: auth.AuthMethodBasic, + url: "https://explicit-basic.atlassian.net/wiki", + email: "explicit-basic@example.com", + cloudID: "explicit-basic-cloud", + checkClient: func(t *testing.T, client *api.Client, token string) { + t.Helper() + if client.GetBaseURL() != "https://explicit-basic.atlassian.net/wiki" || + client.GetAuthHeader() != auth.BasicAuthHeader("explicit-basic@example.com", token) { + t.Fatal("basic client did not use the explicit config and resolved token") + } + }, + }, + { + name: "bearer", + authMethod: auth.AuthMethodBearer, + url: "https://explicit-bearer.atlassian.net/wiki", + email: "explicit-bearer@example.com", + cloudID: "explicit-bearer-cloud", + checkClient: func(t *testing.T, client *api.Client, token string) { + t.Helper() + wantURL := fmt.Sprintf("%s/ex/confluence/explicit-bearer-cloud/wiki", sharedclient.GatewayBaseURL) + if client.GetBaseURL() != wantURL || client.GetAuthHeader() != auth.BearerAuthHeader(token) { + t.Fatal("bearer client did not use the explicit config and resolved token") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + credtest.Hermetic(t) + keyring.SetBackendSelection("", "") + t.Cleanup(func() { keyring.SetBackendSelection("", "") }) + for _, name := range []string{ + "CFL_URL", "ATLASSIAN_URL", "CFL_EMAIL", "ATLASSIAN_EMAIL", + "CFL_AUTH_METHOD", "ATLASSIAN_AUTH_METHOD", "CFL_CLOUD_ID", + "ATLASSIAN_CLOUD_ID", "CFL_DEFAULT_SPACE", + } { + t.Setenv(name, "") + } + + defaultCfg := &config.Config{ + URL: "https://default.atlassian.net/wiki", + Email: "default@example.com", + AuthMethod: auth.AuthMethodBasic, + CloudID: "default-cloud", + DefaultSpace: "DEFAULT", + OutputFormat: "default-output", + Keyring: config.KeyringConfig{Backend: "file"}, + } + testutil.RequireNoError(t, defaultCfg.Save(config.DefaultConfigPath())) + + explicitPath := filepath.Join(t.TempDir(), "explicit.yml") + explicitCfg := &config.Config{ + URL: tt.url, + Email: tt.email, + AuthMethod: tt.authMethod, + CloudID: tt.cloudID, + DefaultSpace: "EXPLICIT", + OutputFormat: "explicit-output", + Keyring: config.KeyringConfig{Backend: "memory"}, + } + testutil.RequireNoError(t, explicitCfg.Save(explicitPath)) + + secret := "explicit-token-" + tt.name + t.Setenv("CFL_API_TOKEN", secret) + var stdout, stderr bytes.Buffer + rootCmd, opts := NewCmd() + rootCmd.SetOut(&stdout) + rootCmd.SetErr(&stderr) + rootCmd.AddCommand(&cobra.Command{ + Use: "probe", + RunE: func(*cobra.Command, []string) error { + cfg, err := opts.Config() + if err != nil { + return err + } + if cfg.URL != explicitCfg.URL || cfg.Email != explicitCfg.Email || + cfg.AuthMethod != explicitCfg.AuthMethod || cfg.CloudID != explicitCfg.CloudID || + cfg.DefaultSpace != explicitCfg.DefaultSpace || cfg.OutputFormat != explicitCfg.OutputFormat || + cfg.Keyring.Backend != explicitCfg.Keyring.Backend || cfg.APIToken != secret { + t.Fatal("resolved config did not use every explicit setting and the authoritative token resolver") + } + client, err := opts.APIClient() + if err != nil { + return err + } + tt.checkClient(t, client, secret) + return nil + }, + }) + rootCmd.SetArgs([]string{"--config", explicitPath, "probe"}) + + err := rootCmd.Execute() + if err != nil { + t.Fatalf("Execute failed without exposing credentials: %v", err) + } + if opts.ConfigPath != explicitPath { + t.Fatal("--config did not set the authoritative path") + } + _, configBackend := keyring.GetBackendSelection() + if configBackend != cccredstore.BackendMemory { + t.Fatal("keyring backend did not come from the explicit config") + } + if bytes.Contains(stdout.Bytes(), []byte(secret)) || bytes.Contains(stderr.Bytes(), []byte(secret)) { + t.Fatal("command output exposed the API token") + } + }) + } +} diff --git a/tools/cfl/internal/config/config.go b/tools/cfl/internal/config/config.go index 0788f3d2..3e1550f2 100644 --- a/tools/cfl/internal/config/config.go +++ b/tools/cfl/internal/config/config.go @@ -223,7 +223,8 @@ func warnCorruptSharedOnce(err error) { }) } -// LoadWithEnv loads configuration with full precedence. +// LoadWithEnv loads configuration with full precedence. Callers may defer +// token resolution until after applying the loaded keyring backend. // // Non-secret fields (url, email, auth_method, cloud_id, default_space, // output_format): @@ -242,7 +243,7 @@ func warnCorruptSharedOnce(err error) { // + env so a broken shared file doesn't crash every cfl command. Init // uses credstore.Load directly so it can surface the error and refuse // to overwrite. -func LoadWithEnv(path string) (*Config, error) { +func LoadWithEnv(path string, resolveToken bool) (*Config, error) { cfg, err := Load(path) if err != nil { // Legacy file missing or corrupt: start empty. cfl init has a @@ -267,13 +268,22 @@ func LoadWithEnv(path string) (*Config, error) { } cfg.LoadFromEnv() + if resolveToken { + if err := ResolveToken(cfg); err != nil { + return nil, err + } + } + return cfg, nil +} +// ResolveToken applies the authoritative token source to cfg. +func ResolveToken(cfg *Config) error { // Authoritative token resolution: overwrites any token a legacy-file // parse may have populated, so plaintext can never reach the client. tok, _, kErr := keyring.ResolveToken(credstore.ToolCFL) if kErr != nil { - return nil, kErr + return kErr } cfg.APIToken = tok - return cfg, nil + return nil } diff --git a/tools/cfl/internal/config/config_test.go b/tools/cfl/internal/config/config_test.go index 90816972..ed603b2b 100644 --- a/tools/cfl/internal/config/config_test.go +++ b/tools/cfl/internal/config/config_test.go @@ -557,7 +557,7 @@ func TestLoadWithEnv_PrecedenceLegacyToSharedToEnv(t *testing.T) { // Set CFL_API_TOKEN env (highest precedence). t.Setenv("CFL_API_TOKEN", "env-tok") - cfg, err := LoadWithEnv(legacyPath) + cfg, err := LoadWithEnv(legacyPath, true) testutil.RequireNoError(t, err) // URL: shared wins over legacy (env not set for URL). @@ -585,7 +585,7 @@ func TestLoadWithEnv_CorruptSharedFallsBackToLegacy(t *testing.T) { legacy := &Config{URL: "https://x.atlassian.net/wiki", Email: "e@x", APIToken: "t"} testutil.RequireNoError(t, legacy.Save(legacyPath)) - cfg, err := LoadWithEnv(legacyPath) + cfg, err := LoadWithEnv(legacyPath, true) testutil.RequireNoError(t, err) testutil.Equal(t, "https://x.atlassian.net/wiki", cfg.URL) // Corrupt shared store defers migration; keyring is empty → no token, From efa4cbbbe4b6084b52bc3da4aa36af80226223f1 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Fri, 17 Jul 2026 07:25:42 -0400 Subject: [PATCH 02/12] feat(cfl)!: standardize page body formats with --body-format (#455) --- README.md | 7 +- docs/ARTIFACT_CONTRACT.md | 25 +- skills/Confluence/CliReference.md | 15 +- skills/Confluence/SKILL.md | 8 +- skills/Confluence/Workflows/ManagePage.md | 15 +- skills/Confluence/Workflows/ViewPage.md | 17 +- tools/cfl/CHANGELOG.md | 2 +- tools/cfl/README.md | 43 ++- tools/cfl/chaos-testing.md | 3 +- tools/cfl/cmd/cfl/main.go | 3 +- tools/cfl/integration-tests.md | 21 +- tools/cfl/internal/cmd/OUTPUT_SPEC.md | 9 +- tools/cfl/internal/cmd/page/body_format.go | 72 +++++ .../cfl/internal/cmd/page/body_format_test.go | 90 ++++++ tools/cfl/internal/cmd/page/create.go | 207 ++++++------- tools/cfl/internal/cmd/page/create_test.go | 81 +++-- tools/cfl/internal/cmd/page/edit.go | 278 ++++++++---------- tools/cfl/internal/cmd/page/edit_test.go | 159 +++++++--- tools/cfl/internal/cmd/page/fetch.go | 50 ++++ tools/cfl/internal/cmd/page/fetch_test.go | 35 +++ tools/cfl/internal/cmd/page/view.go | 64 ++-- tools/cfl/internal/cmd/page/view_test.go | 44 +-- tools/cfl/internal/pageview/projection.go | 90 +++--- .../cfl/internal/pageview/projection_test.go | 113 ++----- tools/cfl/internal/present/README.md | 2 +- tools/cfl/internal/present/detail.go | 17 -- tools/cfl/internal/present/detail_test.go | 11 +- tools/cfl/internal/present/emit.go | 17 ++ tools/cfl/scripts/roundtrip-test.sh | 6 +- 29 files changed, 863 insertions(+), 641 deletions(-) create mode 100644 tools/cfl/internal/cmd/page/body_format.go create mode 100644 tools/cfl/internal/cmd/page/body_format_test.go diff --git a/README.md b/README.md index 196179ab..176b75a2 100644 --- a/README.md +++ b/README.md @@ -237,13 +237,12 @@ cfl page list --space DEV ### Output Representations -Both tools support three output representations: +Both tools support two output artifact modes: | Flag | Representation | Description | |------|----------------|-------------| | (default) | `agent` | Action-oriented output with essential fields. Optimized for LLM/agent consumption. | | `--full` | `full` | Inspection-oriented output with additional fields (dates, authors, versions). | -| `--raw` | `raw` | Source-faithful content (e.g., XHTML instead of markdown). Command-specific. | **Examples:** @@ -256,8 +255,8 @@ cfl page view 123456 jtk issues get PROJ-123 --full cfl page view 123456 --full -# Raw content (cfl only, where content transformation occurs) -cfl page view 123456 --raw +# Exact Confluence body representation +cfl page view 123456 --body-format xhtml ``` See [docs/ARTIFACT_CONTRACT.md](docs/ARTIFACT_CONTRACT.md) for the full output contract specification. diff --git a/docs/ARTIFACT_CONTRACT.md b/docs/ARTIFACT_CONTRACT.md index a10d4155..c208667e 100644 --- a/docs/ARTIFACT_CONTRACT.md +++ b/docs/ARTIFACT_CONTRACT.md @@ -36,28 +36,27 @@ The full artifact provides richer inspection-oriented output for debugging and d - `jtk issues get --full` → agent fields + created, updated, reporter, components, labels - `cfl page view --full` → agent fields + version, created, modified, author -## Raw Mode (Command-Specific) +## Page Body Format (cfl) -Some commands expose a `--raw` mode for source-faithful content where transformation would lose fidelity. This is **not** a general artifact type—it applies only to commands that transform content. +`cfl page view`, `page create`, and `page edit` select body representation independently from artifact breadth with `--body-format markdown|adf|xhtml`. -- Shows original storage format (XHTML, ADF JSON) instead of transformed content -- Errors on commands where raw has no meaning (e.g., list commands) -- Mutually exclusive with `--full` +- Omission means Markdown. +- `adf` is exact Atlassian Document Format JSON. +- `xhtml` is exact Confluence storage XHTML. +- Markdown conversion fails closed; it never emits raw ADF or XHTML as Markdown. **Example:** -- `cfl page view --raw` → XHTML storage format instead of markdown +- `cfl page view PAGE_ID --body-format xhtml` → exact storage XHTML ## Flag Behavior ``` -(none) → agent artifact (curated, transformed content) ---full → full artifact (richer curation, transformed content) ---raw → raw mode (source-faithful content, command-specific) ---full --raw → error: mutually exclusive +(none) → agent artifact with Markdown page bodies +--full → full artifact with Markdown page bodies +--body-format adf → selected artifact with exact ADF body +--body-format xhtml → selected artifact with exact storage XHTML body ``` -> **Implementation note:** The mutual exclusivity constraint (`--full --raw → error`) and command-specific `--raw` validation are forward-looking requirements. They will be enforced as commands are migrated in #199 and #200. - ## Output Format JTK and CFL both use text-first output. The `-o json` resource surface has been removed from both tools (JTK earlier, then CFL via #392); CFL retains `-o table` and `-o plain` only. JSON is reserved for control-plane envelopes (`cfl set-credential --json`, `jtk set-credential --json`) and round-trip payloads (`jtk automation export`). @@ -76,7 +75,7 @@ JTK and CFL both use text-first output. The `-o json` resource surface has been 2. **Agent is the default.** LLM/agent consumption is the primary use case. Human inspection is opt-in via each tool's additive inspection flag set. -3. **Raw is command-specific.** Not every command needs `--raw`. It's only for commands where content transformation occurs. +3. **Body representation is independent.** CFL page commands use `--body-format`; artifact breadth remains controlled separately. 4. **Curated, not pass-through.** Even `--full` is curated by the CLI. Raw API payloads are not exposed directly. diff --git a/skills/Confluence/CliReference.md b/skills/Confluence/CliReference.md index 4b79ea72..ace7c784 100644 --- a/skills/Confluence/CliReference.md +++ b/skills/Confluence/CliReference.md @@ -26,7 +26,6 @@ cfl config test |------|-------------| | `-o, --output FORMAT` | Output format (see SKILL.md "Output Representation and Format"): `table` (default), `json`, `plain` | | `--full` | Inspection-oriented representation (see SKILL.md). Not a content-truncation flag — for `page view` content truncation, use `--no-truncate`. | -| `--raw` | Source-faithful representation (see SKILL.md). **Not a true global flag** — only registered on `page view`. | | `--no-color` | Disable colored output | | `-c, --config PATH` | Override config file location (default: `~/.config/cfl/config.yml`) | @@ -44,7 +43,8 @@ cfl [resource] [action] [ID] [flags] | `cfl page view PAGE_ID` | View page content as markdown (truncated at 5000 chars by default) | | `cfl page view PAGE_ID --no-truncate` | View full content without truncation | | `cfl page view PAGE_ID --content-only` | Output content only (no metadata headers); implies `--no-truncate` | -| `cfl page view PAGE_ID --raw` | View raw Confluence storage format (XHTML) instead of markdown | +| `cfl page view PAGE_ID --body-format xhtml` | View exact Confluence storage XHTML | +| `cfl page view PAGE_ID --body-format adf` | View exact ADF JSON | | `cfl page view PAGE_ID --show-macros` | Show macro placeholders (e.g. `[TOC]`) instead of stripping them | | `cfl page view PAGE_ID --web` | Open page in browser | | `cfl page view PAGE_ID -o json` | Full JSON output (body always included in full) | @@ -70,9 +70,8 @@ cfl [resource] [action] [ID] [flags] | `--title "TEXT"` / `-t` | Page title (required for create) | | `--file PATH` / `-f` | Read content from file | | `--parent PAGE_ID` / `-p` | Parent page ID | -| `--legacy` | Use legacy editor format instead of cloud (ADF) | -| `--no-markdown` | Disable markdown conversion (use raw XHTML) | -| `--storage` | Input is Confluence storage format (XHTML); sent via storage representation API regardless of the page's editor type | +| `--body-format markdown\|adf\|xhtml` | Input/editor representation; defaults to Markdown | +| `--legacy` | Convert Markdown to storage XHTML instead of ADF; invalid with ADF/XHTML input | | `--editor` | Open interactive editor | ### Page View Flags @@ -81,7 +80,7 @@ cfl [resource] [action] [ID] [flags] |------|-------------| | `--no-truncate` | Show full content without truncation | | `--content-only` | Output only page content (no metadata headers); implies `--no-truncate` | -| `--raw` | Raw Confluence storage format (XHTML) | +| `--body-format markdown\|adf\|xhtml` | Body representation; ADF and XHTML are emitted exactly | | `--show-macros` | Show macro placeholders (e.g. `[TOC]`) instead of stripping them | | `-w, --web` | Open in browser | @@ -102,9 +101,9 @@ cfl page view 12345 --content-only | cfl page edit 12345 --legacy ``` Storage-format round-trip (lossless — preserves macros and all formatting): -- Fetch the page with `-o json` (the `content` field holds the raw storage XHTML) +- Fetch with `cfl page view PAGE_ID --body-format xhtml --content-only` - Modify the XHTML -- Send the modified XHTML back: `cfl page edit PAGE_ID --storage` (reads from stdin, or pass via `--file`) +- Send it back with `cfl page edit PAGE_ID --body-format xhtml` (stdin or `--file`) See ViewPage.md for the JSON output structure and ManagePage.md for a full walkthrough. diff --git a/skills/Confluence/SKILL.md b/skills/Confluence/SKILL.md index dc8e1997..7bfe1844 100644 --- a/skills/Confluence/SKILL.md +++ b/skills/Confluence/SKILL.md @@ -42,12 +42,12 @@ No defaults exist. Ask the user. Do not guess. If the user provides a Confluence ### Output Representation and Format -`cfl` distinguishes two independent output concerns (per the repo's [Artifact Contract](../../docs/ARTIFACT_CONTRACT.md)): +`cfl` distinguishes output artifact breadth from page-body representation (per the repo's [Artifact Contract](../../docs/ARTIFACT_CONTRACT.md)): - **Representation** — what content is shown: - `agent` (default) — curated, action-oriented, LLM-optimized - `full` (`--full`) — inspection-oriented, additional fields (dates, authors, versions) - - `raw` (`--raw`) — source-faithful content (e.g., XHTML instead of markdown). Command-specific; only supported where source transformation occurs (currently `page view`). +- **Page body format** — `--body-format markdown|adf|xhtml` on page view/create/edit. Omission means Markdown; ADF and XHTML are exact. - **Output format** — how it's rendered: `table` (default), `json` (`-o json`), `plain` (`-o plain`) They combine freely — e.g., `--full -o json` returns the inspection representation as JSON. @@ -71,8 +71,8 @@ Use that numeric segment as the `PAGE_ID` in any command that takes one. | `cfl config test` fails after `cfl init` | URL typo, wrong instance, or token scoped to a different product | Re-run `cfl init` and double-check the URL and token | | `permission denied` on a specific page/space | Account lacks permission on that space | Verify space membership; ask a space admin to grant access | | `not found` on a valid-looking page ID | Wrong ID, page deleted/archived, or insufficient permission (Confluence may return 404 for unauthorized reads) | Try `cfl search --title "..." --space KEY` to re-locate | -| Page body looks empty or missing structure | Macros stripped by default markdown rendering | Use `cfl page view ID --show-macros` to preserve macro placeholders, or `--raw` for full storage format | -| Edit via markdown loses formatting | Markdown round-trip is lossy for macro-rich pages | Use the storage-format round-trip (fetch via `-o json`, modify the `content` field, send back with `--storage`) — see ManagePage.md | +| Page body looks empty or missing structure | Macros stripped by default markdown rendering | Use `cfl page view ID --show-macros` to preserve placeholders, or `--body-format xhtml` for exact storage XHTML | +| Edit via markdown loses formatting | Markdown round-trip is lossy for macro-rich pages | Round-trip `--body-format xhtml` on both view and edit — see ManagePage.md | ## Workflow Routing diff --git a/skills/Confluence/Workflows/ManagePage.md b/skills/Confluence/Workflows/ManagePage.md index 7e84fe68..e2b57f37 100644 --- a/skills/Confluence/Workflows/ManagePage.md +++ b/skills/Confluence/Workflows/ManagePage.md @@ -22,8 +22,9 @@ Create, edit, copy, move, and delete Confluence pages. | "from file", "use this file" | `--file PATH` | Reads content from file | | provides content inline | Pipe via stdin | `echo "content" \| cfl page create ...` | | "open editor" | `--editor` | Opens interactive editor | -| "legacy format" | `--legacy` | Uses legacy editor format | -| "raw XHTML", "storage format" | `--storage` | Sends raw Confluence XHTML | +| "legacy format" | `--legacy` | Converts Markdown to storage XHTML | +| "raw XHTML", "storage format" | `--body-format xhtml` | Sends exact Confluence XHTML | +| "ADF" | `--body-format adf` | Validates and sends exact ADF JSON | ## Execute @@ -40,10 +41,10 @@ cfl page create --space KEY --title "Child Page" --parent PARENT_ID --file conte echo "# Page Content" | cfl page create --space KEY --title "Page Title" # From raw Confluence storage format (XHTML) — preserves macros exactly -cfl page create --space KEY --title "Page Title" --storage --file content.xhtml +cfl page create --space KEY --title "Page Title" --body-format xhtml --file content.xhtml ``` -The `--storage` flag works on `page create` as well as `page edit`. Use it when the source content is already raw Confluence XHTML (e.g. copied from another page's storage format). +`--body-format xhtml` works on both create and edit. Use it when the source is already exact Confluence storage XHTML. If the user provides content as part of the request, write it to a temp file and use `--file`. Use `mktemp` to avoid collisions: ```bash @@ -91,11 +92,11 @@ rm -f "$TMPFILE" The markdown round-trip above is convenient but **lossy** — macros (TOC, include, status, etc.) and some formatting are stripped. For edits that must preserve everything: -- Fetch the page with `-o json` (see ViewPage.md for the JSON structure) — the `content` field holds the raw storage XHTML +- Fetch with `cfl page view PAGE_ID --body-format xhtml --content-only` - Modify the XHTML as needed -- Send the modified XHTML back via `cfl page edit PAGE_ID --storage` — reads from stdin, or pass via `--file` +- Send it back via `cfl page edit PAGE_ID --body-format xhtml` — stdin or `--file` -The `--storage` flag sends the input directly via the storage representation API, preserving all macros and formatting exactly. Use this whenever: +The XHTML body format sends input directly via the storage representation API, preserving all macros and formatting exactly. Use this whenever: - The page contains macros you need to keep (TOC, include, excerpt, status, etc.) - You're doing a find/replace that must not touch surrounding markup - You're scripting against pages with complex structure diff --git a/skills/Confluence/Workflows/ViewPage.md b/skills/Confluence/Workflows/ViewPage.md index 9cd6071a..061f9c25 100644 --- a/skills/Confluence/Workflows/ViewPage.md +++ b/skills/Confluence/Workflows/ViewPage.md @@ -6,9 +6,9 @@ View Confluence page content and metadata. ### Truncation Rule -See SKILL.md "Output Representation and Format" for the global representation (`agent`/`full`/`raw`) and format (`table`/`json`/`plain`) concepts. +See SKILL.md "Output Representation and Format" for artifact breadth and page-body representation. -The default markdown body is truncated at 5000 chars. This applies to the default view, `--raw`, and `--show-macros`. To get the full body, combine any of these with `--no-truncate`, or use `--content-only` (which implies `--no-truncate`). `-o json` is always full — no truncation, regardless of representation or flags. +The default Markdown body is truncated at 5000 chars. This also applies to ADF, XHTML, and `--show-macros`. Use `--no-truncate`, or `--content-only` (which implies it), for the complete body. `--content-only` already implies `--no-truncate`; don't combine them. @@ -19,7 +19,8 @@ The default markdown body is truncated at 5000 chars. This applies to the defaul | "view page", "show page", "read page" | `cfl page view PAGE_ID` | Default markdown view (subject to truncation) | | "show full page", "all content", "no truncation" | `cfl page view PAGE_ID --no-truncate` | Full content without truncation | | "just the content", "content only" | `cfl page view PAGE_ID --content-only` | Content without metadata headers (implies `--no-truncate`) | -| "raw format", "XHTML", "storage format" | `cfl page view PAGE_ID --raw` | Raw Confluence storage format (subject to truncation) | +| "XHTML", "storage format" | `cfl page view PAGE_ID --body-format xhtml` | Exact storage XHTML (subject to truncation) | +| "ADF", "Atlassian document format" | `cfl page view PAGE_ID --body-format adf` | Exact ADF JSON (subject to truncation) | | "show macros" | `cfl page view PAGE_ID --show-macros` | Preserve macro placeholders like `[TOC]` (subject to truncation) | | "open in browser", "open page" | `cfl page view PAGE_ID --web` | Opens in default browser | | "page as JSON" | `cfl page view PAGE_ID -o json` | Full JSON output (body always included in full — no truncation) | @@ -50,11 +51,11 @@ cfl page view PAGE_ID --content-only # Preserve macros that would otherwise be stripped cfl page view PAGE_ID --show-macros -# Raw storage format (XHTML) — also subject to default truncation -cfl page view PAGE_ID --raw +# Exact storage XHTML — also subject to default truncation +cfl page view PAGE_ID --body-format xhtml -# Full raw storage format (no truncation) -cfl page view PAGE_ID --raw --no-truncate +# Full exact ADF JSON (no truncation) +cfl page view PAGE_ID --body-format adf --no-truncate # Open in browser cfl page view PAGE_ID --web @@ -87,7 +88,7 @@ Present page content clearly: - Show page title, space, last modified date, and version at the top - Show the page body in markdown format - If truncated, note that and offer `--no-truncate` or `--content-only` -- For raw format, note it's XHTML storage format +- For exact formats, identify ADF JSON or storage XHTML ## Post-Action diff --git a/tools/cfl/CHANGELOG.md b/tools/cfl/CHANGELOG.md index 8b8a6e43..6b92ed8d 100644 --- a/tools/cfl/CHANGELOG.md +++ b/tools/cfl/CHANGELOG.md @@ -9,7 +9,6 @@ ### Added - Service account support with bearer auth (`--auth-method bearer`) for scoped API tokens ([#171](https://github.com/open-cli-collective/atlassian-cli/pull/171)) -- `--storage` flag on `page create` and `page edit` to pipe Confluence storage format (XHTML) directly ([#144](https://github.com/open-cli-collective/atlassian-cli/pull/144)) - Wiki-link syntax `[[Page Title]]` and `[[SPACE:Page Title]]` for internal Confluence page links ([#129](https://github.com/open-cli-collective/atlassian-cli/pull/129)) - `space view`, `space create`, `space update`, `space delete` commands for full space management ([#151](https://github.com/open-cli-collective/atlassian-cli/issues/151)) - ADF-to-Markdown converter (`pkg/md.FromADF`) for rendering Atlassian Document Format pages as markdown ([#150](https://github.com/open-cli-collective/atlassian-cli/issues/150)) @@ -20,6 +19,7 @@ ### Changed +- `page view`, `page create`, and `page edit` now use `--body-format markdown|adf|xhtml`; removed `--raw`, `--storage`, and `--no-markdown`, made editor buffers representation-safe, and made Markdown conversion fail closed ([#455](https://github.com/open-cli-collective/atlassian-cli/issues/455)) - Improved init and config test UX with user details display ([#56](https://github.com/open-cli-collective/atlassian-cli/pull/56)) ## [0.9.0](https://github.com/open-cli-collective/confluence-cli/compare/v0.8.1...v0.9.0) (2026-01-16) diff --git a/tools/cfl/README.md b/tools/cfl/README.md index 4e7689d4..f3d1904e 100644 --- a/tools/cfl/README.md +++ b/tools/cfl/README.md @@ -286,7 +286,8 @@ View a Confluence page. **Content is displayed as markdown by default.** ```bash cfl page view 12345 -cfl page view 12345 --raw +cfl page view 12345 --body-format xhtml +cfl page view 12345 --body-format adf cfl page view 12345 --version 7 cfl page view 12345 --web cfl page view 12345 --content-only # Output only content (no headers) @@ -295,7 +296,7 @@ cfl page view 12345 --show-macros --content-only | cfl page edit 12345 --legacy | Flag | Short | Default | Description | |------|-------|---------|-------------| -| `--raw` | | `false` | Show raw Confluence format instead of markdown (XHTML storage format, or ADF JSON if storage is empty) | +| `--body-format` | | `markdown` | Body representation: `markdown`, exact `adf` JSON, or exact storage `xhtml` | | `--web` | `-w` | `false` | Open page in browser instead of displaying | | `--version` | | `0` | View a specific page version | | `--no-truncate` | | `false` | Show full content without truncation | @@ -340,7 +341,7 @@ Content can be provided via: - Standard input (pipe content) - Interactive editor (default) -**Markdown is the default format.** Content is automatically converted to Confluence storage format. +**Markdown is the default format.** It is converted to ADF, or to storage XHTML with `--legacy`. ```bash # Open markdown editor @@ -352,14 +353,11 @@ cfl page create -s DEV -t "My Page" --file content.md # Create from markdown stdin echo "# Hello World" | cfl page create -s DEV -t "My Page" -# Create from XHTML file (auto-detected by extension) -cfl page create -s DEV -t "My Page" --file content.html +# Create from exact ADF JSON +cfl page create -s DEV -t "My Page" --file content.json --body-format adf -# Create from XHTML stdin (disable markdown conversion) -echo "

Hello

" | cfl page create -s DEV -t "My Page" --no-markdown - -# Create from storage format XHTML (sent via storage representation API) -echo "

Hello

" | cfl page create -s DEV -t "My Page" --storage +# Create from exact storage XHTML +echo "

Hello

" | cfl page create -s DEV -t "My Page" --body-format xhtml # Create as child of another page cfl page create -s DEV -t "Child Page" --parent 12345 @@ -375,14 +373,10 @@ cfl page create -s DEV -t "Legacy Page" --file content.md --legacy | `--parent` | `-p` | | Parent page ID (for nested pages) | | `--file` | `-f` | | Read content from file | | `--editor` | | `false` | Force open in $EDITOR | -| `--no-markdown` | | `false` | Disable markdown conversion (use raw XHTML) | -| `--storage` | | `false` | Input is Confluence storage format (XHTML); sends via storage representation API | -| `--legacy` | | `false` | Use legacy storage format instead of cloud editor (ADF) | +| `--body-format` | | `markdown` | Input format: `markdown`, exact `adf` JSON, or exact storage `xhtml` | +| `--legacy` | | `false` | Convert Markdown to storage XHTML instead of ADF; invalid with `adf` or `xhtml` | -**Format detection:** -- `.md`, `.markdown` files → markdown (converted to XHTML) -- `.html`, `.xhtml`, `.htm` files → XHTML (used as-is) -- stdin, editor → markdown by default (use `--no-markdown` for XHTML) +The selected format applies equally to files, stdin, and editor input; file extensions do not override it. --- @@ -395,7 +389,7 @@ Content can be provided via: - Standard input (pipe content) - Interactive editor (default, opens with existing content) -**Markdown is the default format.** Content is automatically converted to Confluence storage format. +**Markdown is the default format.** It is converted to ADF, or to storage XHTML with `--legacy`. ```bash # Open editor with existing page content @@ -419,12 +413,12 @@ cfl page edit 12345 --parent 67890 --title "New Title" # Edit using legacy storage format (for pages created in legacy editor) cfl page edit 12345 --file content.md --legacy -# Pipe raw Confluence storage format (XHTML) directly -echo "

Updated

" | cfl page edit 12345 --storage +# Pipe exact Confluence storage XHTML directly +echo "

Updated

" | cfl page edit 12345 --body-format xhtml # Extract, transform, and re-upload storage-format content -cfl page view 12345 --raw --content-only | \ - sed 's/old/new/g' | cfl page edit 12345 --storage +cfl page view 12345 --body-format xhtml --content-only | \ + sed 's/old/new/g' | cfl page edit 12345 --body-format xhtml ``` | Flag | Short | Default | Description | @@ -433,9 +427,8 @@ cfl page view 12345 --raw --content-only | \ | `--parent` | `-p` | | Move page to new parent page ID | | `--file` | `-f` | | Read content from file | | `--editor` | | `false` | Force open in $EDITOR | -| `--no-markdown` | | `false` | Disable markdown conversion (use raw XHTML) | -| `--storage` | | `false` | Input is Confluence storage format (XHTML); sends via storage representation API | -| `--legacy` | | `false` | Use legacy storage format instead of cloud editor (ADF) | +| `--body-format` | | `markdown` | Input/editor format: `markdown`, exact `adf` JSON, or exact storage `xhtml` | +| `--legacy` | | `false` | Convert Markdown to storage XHTML instead of ADF; invalid with `adf` or `xhtml` | **Arguments:** - `` - The page ID (**required**) diff --git a/tools/cfl/chaos-testing.md b/tools/cfl/chaos-testing.md index f433cb86..bfe43ada 100644 --- a/tools/cfl/chaos-testing.md +++ b/tools/cfl/chaos-testing.md @@ -83,7 +83,7 @@ Things where the current behavior might be correct, but we should confirm: | Invalid page ID (999999999) | PASS | Good 404 error | | Non-numeric ID (abc) | PASS | Good 400 error | | Missing page ID | PASS | "accepts 1 arg(s)" error | -| `--raw` mode | PASS | Shows Confluence storage format | +| `--body-format xhtml` mode | PASS | Shows exact Confluence storage XHTML | | `-o json` | PASS | Valid JSON (no trailing message!) | | Confluence macros | BUG-005 | TOC macro params leak through | @@ -123,4 +123,3 @@ Things where the current behavior might be correct, but we should confirm: - [ ] Test `--web` flag - [ ] Find page with code blocks to test syntax highlighting conversion - [x] Test aliases (ls vs list) - DONE (works) - diff --git a/tools/cfl/cmd/cfl/main.go b/tools/cfl/cmd/cfl/main.go index a066efe0..cb74636b 100644 --- a/tools/cfl/cmd/cfl/main.go +++ b/tools/cfl/cmd/cfl/main.go @@ -26,6 +26,7 @@ import ( "github.com/open-cli-collective/confluence-cli/internal/cmd/search" "github.com/open-cli-collective/confluence-cli/internal/cmd/setcredential" "github.com/open-cli-collective/confluence-cli/internal/cmd/space" + cflpresent "github.com/open-cli-collective/confluence-cli/internal/present" ) func main() { @@ -54,7 +55,7 @@ func main() { if err != nil { // set-credential --json may have already emitted its envelope on // stdout; in that case stderr stays empty per §1.5.2. - if !errors.Is(err, keyring.ErrSetCredentialEnvelopeEmitted) { + if !errors.Is(err, keyring.ErrSetCredentialEnvelopeEmitted) && !errors.Is(err, cflpresent.ErrEmitted) { fmt.Fprintf(os.Stderr, "Error: %s\n", err) } os.Exit(exitcode.GeneralError) diff --git a/tools/cfl/integration-tests.md b/tools/cfl/integration-tests.md index f4ea3b63..8114c512 100644 --- a/tools/cfl/integration-tests.md +++ b/tools/cfl/integration-tests.md @@ -74,11 +74,12 @@ All cfl commands should work with both auth methods (no scope limitations for Co | Test Case | Command | Expected Result | |-----------|---------|-----------------| | View page content | `cfl page view ` | Shows title, ID, version, and markdown content | -| View raw HTML | `cfl page view --raw` | Shows Confluence storage format (XHTML) | +| View exact XHTML | `cfl page view --body-format xhtml` | Shows exact Confluence storage XHTML | +| View exact ADF | `cfl page view --body-format adf` | Shows exact ADF JSON | | ~~JSON output~~ (#392 removed) | `cfl page view --output json` | Errors: invalid output format | | Non-existent page | `cfl page view 99999999999` | Error: 404 not found | | View content only | `cfl page view --content-only` | Markdown only, no Title/ID/Version headers | -| Content only with raw | `cfl page view --content-only --raw` | XHTML only, no headers | +| Content only with XHTML | `cfl page view --content-only --body-format xhtml` | XHTML only, no headers | | Content only with macros | `cfl page view --content-only --show-macros` | Markdown with [TOC] etc., no headers | | Roundtrip macros (content-only) | `cfl page view --show-macros --content-only \| cfl page edit --legacy` | Macros preserved | | ~~Content only JSON error~~ (#392 removed; -o json itself errors before --content-only checks) | `cfl page view --content-only -o json` | Errors: invalid output format | @@ -91,7 +92,7 @@ All cfl commands should work with both auth methods (no scope limitations for Co | Create from stdin | `echo "# Test" \| cfl page create -s confluence -t "Test Page"` | Page created, shows ID and URL | | Create from file | `cfl page create -s confluence -t "Test" --file content.md` | Page created from file content | | Create child page | `cfl page create -s confluence -t "Child" --parent ` | Page created with parentId set | -| Create with XHTML (legacy) | `echo "

Test

" \| cfl page create -s confluence -t "Test" --no-markdown --legacy` | Page created without markdown conversion | +| Create with XHTML | `echo "

Test

" \| cfl page create -s confluence -t "Test" --body-format xhtml` | Exact storage XHTML sent | | Missing title | `cfl page create -s confluence` | Error: title required | | Missing space | `cfl page create -t "Test"` | Error: space required | | Duplicate title | Create same title twice | Error: "page already exists with same TITLE" | @@ -107,9 +108,9 @@ All cfl commands should work with both auth methods (no scope limitations for Co | Test Case | Command | Expected Result | |-----------|---------|-----------------| | Edit from file | `cfl page edit --file updated.md` | Page updated, version incremented | -| Edit with --no-markdown (legacy) | `cfl page edit --file content.html --no-markdown --legacy` | Raw XHTML preserved | -| Edit page with tables (markdown mode) | Edit without --no-markdown | See "Confluence UI-Created Content" section | -| Edit page with code blocks (UI-created) | Edit without --no-markdown | See "Confluence UI-Created Content" section | +| Edit with XHTML | `cfl page edit --file content.xhtml --body-format xhtml` | Exact XHTML preserved | +| Edit page with tables (markdown mode) | Edit with omitted/default body format | See "Confluence UI-Created Content" section | +| Edit page with code blocks (UI-created) | Edit with omitted/default body format | See "Confluence UI-Created Content" section | | Non-existent page | `cfl page edit 99999999999` | Error: 404 not found | | Edit (cloud editor) | `cfl page edit --file updated.md` | Page stays in cloud editor format | | Edit (legacy editor) | `cfl page edit --file updated.md --legacy` | Page uses legacy storage format | @@ -466,9 +467,9 @@ curl -s -u "$EMAIL:$TOKEN" "$URL/api/v2/pages/?body-format=atlas_doc_fo | CE-02 | stdin | --legacy | storage | `body.storage` present | | CE-03 | file.md | (none) | ADF | No "Legacy editor" badge | | CE-04 | file.md | --legacy | storage | Shows "Legacy editor" badge | -| CE-05 | file.html | --legacy | storage | Raw HTML passed through | -| CE-06 | stdin | --no-markdown | ADF | Raw content passed through | -| CE-07 | stdin | --no-markdown --legacy | storage | Raw XHTML passed through | +| CE-05 | file.xhtml | --body-format xhtml | storage | Exact XHTML passed through | +| CE-06 | stdin | --body-format adf | ADF | Valid JSON passed through exactly | +| CE-07 | stdin | --body-format xhtml | storage | Exact XHTML passed through | ### Round-Trip Tests @@ -532,7 +533,7 @@ Copies in the TEST space (originals from INT, CUS, PROD, PLAYBOOK): | Test Case | Command | Expected Result | |-----------|---------|-----------------| | View ADF page (default) | `cfl page view ` | Shows markdown content (not "(No content)") | -| View ADF page (raw) | `cfl page view --raw` | Shows raw XHTML (or ADF JSON if storage was empty) | +| View ADF page (ADF) | `cfl page view --body-format adf` | Shows exact ADF JSON | | ~~View ADF page (JSON)~~ (#392 removed) | `cfl page view -o json` | Errors: invalid output format | | View ADF page (content-only) | `cfl page view --content-only` | Shows content without headers | | View legacy page (no regression) | `cfl page view ` | Shows markdown content via storage path | diff --git a/tools/cfl/internal/cmd/OUTPUT_SPEC.md b/tools/cfl/internal/cmd/OUTPUT_SPEC.md index 76685606..51e9bdcf 100644 --- a/tools/cfl/internal/cmd/OUTPUT_SPEC.md +++ b/tools/cfl/internal/cmd/OUTPUT_SPEC.md @@ -17,8 +17,8 @@ does not yet match this document exactly, this spec is the contract to implement needed to decide the next action without extra flags. 3. **`--full` is additive, not alternate.** It reveals richer inspection detail on the same text surface instead of switching to a different representation. -4. **Page content defaults to readable markdown.** `page view` should expose - transformed markdown by default and reserve `--raw` for source-faithful storage. +4. **Page content defaults to readable markdown.** `page view` exposes Markdown + by default and exact ADF or storage XHTML through `--body-format`. 5. **Padding is not semantics.** Delimiters and labels are stable; visual padding, ASCII-boxing, and JSON wrappers are not part of the contract. 6. **Control-plane JSON is separate.** Local boolean `--json` flags such as @@ -32,7 +32,7 @@ does not yet match this document exactly, this spec is the contract to implement | Default | `-o table` | Canonical human + agent text. Detail commands use stable key-value blocks; list commands use stable delimited rows with headers. | | Plain | `-o plain` | Script-oriented dense text. For list commands this is TSV. For detail/mutation commands it must remain semantically identical to default text, differing only in presentation details such as color. | | Full | `--full` | Inspection-oriented additive fields on top of the default/plain contract. | -| Raw | command-specific `--raw` | Source-faithful content for commands that transform body content, primarily `page view`. | +| Body format | `--body-format markdown\|adf\|xhtml` | Page-body representation, independent of output artifact breadth. | ## Global flag semantics @@ -204,7 +204,8 @@ Author ID: Flag-specific behavior: - `--content-only` emits only the body and implies untruncated output. -- `--raw` emits the source body instead of transformed markdown. +- `--body-format` selects Markdown (default), exact ADF JSON, or exact storage XHTML. +- Markdown conversion errors fail with no body on stdout and suggest an exact format. - `--show-macros` affects body conversion only. - `--no-truncate` disables the default body truncation guard. - `--version N` selects a historical version and preserves the same output shape. diff --git a/tools/cfl/internal/cmd/page/body_format.go b/tools/cfl/internal/cmd/page/body_format.go new file mode 100644 index 00000000..a9939993 --- /dev/null +++ b/tools/cfl/internal/cmd/page/body_format.go @@ -0,0 +1,72 @@ +package page + +import ( + "encoding/json" + "fmt" + + "github.com/open-cli-collective/confluence-cli/api" + "github.com/open-cli-collective/confluence-cli/pkg/md" +) + +const ( + bodyFormatMarkdown = "markdown" + bodyFormatADF = "adf" + bodyFormatXHTML = "xhtml" +) + +func resolveBodyFormat(value string, explicit bool) (string, error) { + if value == "" && !explicit { + return bodyFormatMarkdown, nil + } + switch value { + case bodyFormatMarkdown, bodyFormatADF, bodyFormatXHTML: + return value, nil + default: + return "", fmt.Errorf("invalid body format %q: must be markdown, adf, or xhtml", value) + } +} + +func bodyForInput(content, format string, legacy bool) (*api.Body, error) { + if legacy && format != bodyFormatMarkdown { + return nil, fmt.Errorf("--legacy is only supported with --body-format markdown") + } + + switch format { + case bodyFormatMarkdown: + if legacy { + converted, err := md.ToConfluenceStorage([]byte(content)) + if err != nil { + return nil, fmt.Errorf("converting markdown: %w", err) + } + return storageBody(converted), nil + } + converted, err := md.ToADF([]byte(content)) + if err != nil { + return nil, fmt.Errorf("converting markdown to ADF: %w", err) + } + return adfBody(converted), nil + case bodyFormatADF: + if !json.Valid([]byte(content)) { + return nil, fmt.Errorf("invalid ADF JSON") + } + return adfBody(content), nil + case bodyFormatXHTML: + return storageBody(content), nil + default: + return nil, fmt.Errorf("unsupported body format %q", format) + } +} + +func adfBody(content string) *api.Body { + return &api.Body{AtlasDocFormat: &api.BodyRepresentation{ + Representation: "atlas_doc_format", + Value: content, + }} +} + +func storageBody(content string) *api.Body { + return &api.Body{Storage: &api.BodyRepresentation{ + Representation: "storage", + Value: content, + }} +} diff --git a/tools/cfl/internal/cmd/page/body_format_test.go b/tools/cfl/internal/cmd/page/body_format_test.go new file mode 100644 index 00000000..85623c99 --- /dev/null +++ b/tools/cfl/internal/cmd/page/body_format_test.go @@ -0,0 +1,90 @@ +package page + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/atlassian-go/testutil" + + "github.com/open-cli-collective/confluence-cli/api" + "github.com/open-cli-collective/confluence-cli/internal/cmd/root" +) + +func TestBodyForInput(t *testing.T) { + t.Parallel() + + adf := " \n{\"type\":\"doc\",\"version\":1,\"content\":[]}\n" + xhtml := "

exact XHTML

\n" + + markdownBody, err := bodyForInput("# Heading", bodyFormatMarkdown, false) + testutil.RequireNoError(t, err) + testutil.NotNil(t, markdownBody.AtlasDocFormat) + testutil.Contains(t, markdownBody.AtlasDocFormat.Value, `"type":"heading"`) + + adfBody, err := bodyForInput(adf, bodyFormatADF, false) + testutil.RequireNoError(t, err) + testutil.Equal(t, adf, adfBody.AtlasDocFormat.Value) + + xhtmlBody, err := bodyForInput(xhtml, bodyFormatXHTML, false) + testutil.RequireNoError(t, err) + testutil.Equal(t, xhtml, xhtmlBody.Storage.Value) + + _, err = bodyForInput("{broken", bodyFormatADF, false) + testutil.RequireError(t, err) + _, err = bodyForInput(adf, bodyFormatADF, true) + testutil.RequireError(t, err) +} + +func TestBodyFormatValidation(t *testing.T) { + t.Parallel() + for _, value := range []string{"", "pdf"} { + cmd := newViewCmd(&root.Options{}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{"12345", "--body-format=" + value}) + err := cmd.Execute() + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "invalid body format") + } + + cmd := newCreateCmd(&root.Options{}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{"--title", "Page", "--body-format", "adf", "--legacy"}) + err := cmd.Execute() + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "--legacy is only supported") +} + +func TestCreateMalformedADFDoesNotRequest(t *testing.T) { + t.Parallel() + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ })) + defer server.Close() + + rootOpts := &root.Options{Stdin: strings.NewReader("{broken"), Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} + rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) + err := runCreate(context.Background(), &createOptions{ + Options: rootOpts, space: "DEV", title: "Page", bodyFormat: bodyFormatADF, + }) + testutil.RequireError(t, err) + testutil.Equal(t, 0, requests) +} + +func TestBodyFormatFlagsReplaceLegacyFormatFlags(t *testing.T) { + t.Parallel() + rootOpts := &root.Options{} + + for _, cmd := range []*cobra.Command{newViewCmd(rootOpts), newCreateCmd(rootOpts), newEditCmd(rootOpts)} { + testutil.NotNil(t, cmd.Flags().Lookup("body-format")) + testutil.Nil(t, cmd.Flags().Lookup("raw")) + testutil.Nil(t, cmd.Flags().Lookup("storage")) + testutil.Nil(t, cmd.Flags().Lookup("no-markdown")) + } +} diff --git a/tools/cfl/internal/cmd/page/create.go b/tools/cfl/internal/cmd/page/create.go index a5511a3b..f6b911db 100644 --- a/tools/cfl/internal/cmd/page/create.go +++ b/tools/cfl/internal/cmd/page/create.go @@ -6,7 +6,6 @@ import ( "io" "os" "os/exec" - "path/filepath" "strings" "github.com/spf13/cobra" @@ -14,19 +13,18 @@ import ( "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/internal/cmd/root" cflpresent "github.com/open-cli-collective/confluence-cli/internal/present" - "github.com/open-cli-collective/confluence-cli/pkg/md" ) type createOptions struct { *root.Options - space string - title string - parent string - file string - editor bool - markdown *bool // nil = auto-detect, true = force markdown, false = force storage format - legacy bool // Use legacy editor (storage format) instead of cloud editor (ADF) - storage bool // Use storage representation directly (implies --no-markdown) + space string + title string + parent string + file string + editor bool + bodyFormat string + bodyFormatExplicit bool + legacy bool } func newCreateCmd(rootOpts *root.Options) *cobra.Command { @@ -37,20 +35,17 @@ func newCreateCmd(rootOpts *root.Options) *cobra.Command { Short: "Create a new page", Long: `Create a new Confluence page. -By default, pages are created using the cloud editor format (ADF). -Use --legacy to create pages in the legacy editor format. +Markdown input is converted to cloud editor format (ADF) by default. +Use --legacy with Markdown to create pages in storage XHTML instead. Content can be provided via: - --file flag to read from a file (use --file - to read from stdin) - Standard input (pipe content) - Interactive editor with --editor -Content format: -- Markdown is the default for stdin, editor, and .md files -- Use --no-markdown to provide raw Confluence format (XHTML for legacy, ADF JSON for cloud) -- Use --storage to provide raw Confluence storage format (XHTML) and send it directly - via the storage representation API, regardless of the page's editor type -- Files with .html/.xhtml extensions are treated as storage format`, +Content format is selected with --body-format markdown|adf|xhtml. +Omitting --body-format means Markdown. ADF and XHTML are validated or sent +without conversion.`, Example: ` # Create a page with title in the editor (cloud editor format) cfl page create --space DEV --title "My Page" --editor @@ -60,36 +55,35 @@ Content format: # Create in legacy editor format cfl page create -s DEV -t "My Page" --file content.md --legacy - # Create from XHTML file (legacy mode) - cfl page create -s DEV -t "My Page" --file content.html --legacy + # Create from exact ADF JSON + cfl page create -s DEV -t "My Page" --file content.json --body-format adf + + # Create from exact storage XHTML + cfl page create -s DEV -t "My Page" --file content.xhtml --body-format xhtml # Create from stdin (markdown) echo "# Hello World" | cfl page create -s DEV -t "My Page" - # Create from stdin via explicit --file - (e.g. piping HTML as storage) - echo "

Hello

" | cfl page create -s DEV -t "My Page" --file - --storage - - # Create from stdin with legacy format (XHTML) - echo "

Hello

" | cfl page create -s DEV -t "My Page" --no-markdown --legacy - - # Create from storage format XHTML (sent via storage representation API) - echo "

Hello

" | cfl page create -s DEV -t "My Page" --storage + # Create from stdin with exact storage XHTML + echo "

Hello

" | cfl page create -s DEV -t "My Page" --body-format xhtml # Create as child of another page cfl page create -s DEV -t "Child Page" --parent 12345`, - RunE: func(cmd *cobra.Command, _ []string) error { - opts.storage, _ = cmd.Flags().GetBool("storage") - if opts.storage { - // --storage implies --no-markdown (input is raw XHTML) - useMd := false - opts.markdown = &useMd + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.NoArgs(cmd, args); err != nil { + return err } - if cmd.Flags().Changed("no-markdown") { - noMd, _ := cmd.Flags().GetBool("no-markdown") - useMd := !noMd - opts.markdown = &useMd + format, err := resolveBodyFormat(opts.bodyFormat, cmd.Flags().Changed("body-format")) + if err != nil { + return err + } + if opts.legacy && format != bodyFormatMarkdown { + return fmt.Errorf("--legacy is only supported with --body-format markdown") } - opts.legacy, _ = cmd.Flags().GetBool("legacy") + return nil + }, + RunE: func(cmd *cobra.Command, _ []string) error { + opts.bodyFormatExplicit = cmd.Flags().Changed("body-format") return runCreate(cmd.Context(), opts) }, } @@ -99,9 +93,8 @@ Content format: cmd.Flags().StringVarP(&opts.parent, "parent", "p", "", "Parent page ID") cmd.Flags().StringVarP(&opts.file, "file", "f", "", "Read content from file") cmd.Flags().BoolVar(&opts.editor, "editor", false, "Open editor for content") - cmd.Flags().Bool("no-markdown", false, "Disable markdown conversion (use raw XHTML)") - cmd.Flags().Bool("storage", false, "Input is Confluence storage format (XHTML); sends via storage representation API") - cmd.Flags().Bool("legacy", false, "Create page in legacy editor format (default: cloud editor)") + cmd.Flags().StringVar(&opts.bodyFormat, "body-format", bodyFormatMarkdown, "Input format: markdown, adf, or xhtml") + cmd.Flags().BoolVar(&opts.legacy, "legacy", false, "Create page in legacy editor format (Markdown input only)") _ = cmd.MarkFlagRequired("title") @@ -109,6 +102,14 @@ Content format: } func runCreate(ctx context.Context, opts *createOptions) error { + bodyFormat, err := resolveBodyFormat(opts.bodyFormat, opts.bodyFormatExplicit) + if err != nil { + return err + } + if opts.legacy && bodyFormat != bodyFormatMarkdown { + return fmt.Errorf("--legacy is only supported with --body-format markdown") + } + // Validate file exists before making any network calls so we fail // fast on bad input without needing config or API access. "-" means // stdin, which has no path to stat. @@ -120,6 +121,17 @@ func runCreate(ctx context.Context, opts *createOptions) error { if !hasContentSource(opts.Options, opts.file, opts.editor) { return errMissingContentSource() } + content, err := getContent(opts, bodyFormat) + if err != nil { + return err + } + if strings.TrimSpace(content) == "" { + return fmt.Errorf("page content cannot be empty") + } + body, err := bodyForInput(content, bodyFormat, opts.legacy) + if err != nil { + return err + } cfg, err := opts.Config() if err != nil { @@ -145,47 +157,6 @@ func runCreate(ctx context.Context, opts *createOptions) error { return fmt.Errorf("finding space '%s': %w", spaceKey, err) } - content, isMarkdown, err := getContent(opts) - if err != nil { - return err - } - - if strings.TrimSpace(content) == "" { - return fmt.Errorf("page content cannot be empty") - } - - var body *api.Body - - if opts.storage || opts.legacy { - if isMarkdown { - converted, err := md.ToConfluenceStorage([]byte(content)) - if err != nil { - return fmt.Errorf("converting markdown: %w", err) - } - content = converted - } - body = &api.Body{ - Storage: &api.BodyRepresentation{ - Representation: "storage", - Value: content, - }, - } - } else { - if isMarkdown { - adfContent, err := md.ToADF([]byte(content)) - if err != nil { - return fmt.Errorf("converting markdown to ADF: %w", err) - } - content = adfContent - } - body = &api.Body{ - AtlasDocFormat: &api.BodyRepresentation{ - Representation: "atlas_doc_format", - Value: content, - }, - } - } - req := &api.CreatePageRequest{ SpaceID: space.ID, Title: opts.title, @@ -205,73 +176,49 @@ func runCreate(ctx context.Context, opts *createOptions) error { return cflpresent.Emit(opts.Options, cflpresent.PagePresenter{}.PresentCreate(page, cfg.URL)) } -// getContent reads content and returns (content, isMarkdown, error). -// isMarkdown indicates whether the content should be converted from markdown. -func getContent(opts *createOptions) (string, bool, error) { - useMarkdown := func(filename string) bool { - if opts.markdown != nil { - return *opts.markdown - } - if filename != "" { - ext := strings.ToLower(filepath.Ext(filename)) - switch ext { - case ".html", ".xhtml", ".htm": - return false - case ".md", ".markdown": - return true - } - } - return true - } - +func getContent(opts *createOptions, bodyFormat string) (string, error) { if opts.file == "-" { data, err := io.ReadAll(stdinReader(opts.Options)) if err != nil { - return "", false, fmt.Errorf("reading stdin: %w", err) + return "", fmt.Errorf("reading stdin: %w", err) } - return string(data), useMarkdown(""), nil + return string(data), nil } if opts.file != "" { data, err := os.ReadFile(opts.file) if err != nil { - return "", false, fmt.Errorf("reading file: %w", err) + return "", fmt.Errorf("reading file: %w", err) } - return string(data), useMarkdown(opts.file), nil + return string(data), nil } if opts.Stdin != nil && opts.Stdin != os.Stdin { data, err := io.ReadAll(opts.Stdin) if err != nil { - return "", false, fmt.Errorf("reading stdin: %w", err) + return "", fmt.Errorf("reading stdin: %w", err) } - return string(data), useMarkdown(""), nil + return string(data), nil } if hasPipedOSStdin(opts.Options) { data, err := io.ReadAll(os.Stdin) if err != nil { - return "", false, fmt.Errorf("reading stdin: %w", err) + return "", fmt.Errorf("reading stdin: %w", err) } - return string(data), useMarkdown(""), nil + return string(data), nil } if !opts.editor { - return "", false, errMissingContentSource() + return "", errMissingContentSource() } - isMarkdown := useMarkdown("") - content, err := openEditor(isMarkdown) - return content, isMarkdown, err + return openEditor(bodyFormat) } -func openEditor(isMarkdown bool) (string, error) { - ext := ".html" - template := `

Enter your page content here.

-` - if isMarkdown { - ext = ".md" - template = `# Page Title +func openEditor(bodyFormat string) (string, error) { + ext := ".md" + template := `# Page Title Enter your content here using markdown. @@ -279,6 +226,15 @@ Enter your content here using markdown. - List item 1 - List item 2 +` + switch bodyFormat { + case bodyFormatADF: + ext = ".json" + template = `{"type":"doc","version":1,"content":[]} +` + case bodyFormatXHTML: + ext = ".xhtml" + template = `

Enter your page content here.

` } @@ -315,10 +271,13 @@ Enter your content here using markdown. return "", fmt.Errorf("reading edited content: %w", err) } - content := strings.TrimSpace(string(data)) - if content == "" || content == strings.TrimSpace(template) { + content := string(data) + trimmed := strings.TrimSpace(content) + if trimmed == "" || trimmed == strings.TrimSpace(template) { return "", fmt.Errorf("no content provided (or content unchanged)") } - + if bodyFormat == bodyFormatMarkdown { + return trimmed, nil + } return content, nil } diff --git a/tools/cfl/internal/cmd/page/create_test.go b/tools/cfl/internal/cmd/page/create_test.go index 80b441b8..31564e05 100644 --- a/tools/cfl/internal/cmd/page/create_test.go +++ b/tools/cfl/internal/cmd/page/create_test.go @@ -116,11 +116,11 @@ func TestRunCreate_HTMLFile_Legacy(t *testing.T) { rootOpts.SetAPIClient(client) opts := &createOptions{ - Options: rootOpts, - space: "DEV", - title: "Test Page", - file: htmlFile, - legacy: true, // Use legacy mode for HTML files + Options: rootOpts, + space: "DEV", + title: "Test Page", + file: htmlFile, + bodyFormat: bodyFormatXHTML, } err = runCreate(context.Background(), opts) @@ -162,14 +162,12 @@ func TestRunCreate_NoMarkdownFlag_Legacy(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") rootOpts.SetAPIClient(client) - useMd := false opts := &createOptions{ - Options: rootOpts, - space: "DEV", - title: "Test Page", - file: mdFile, - markdown: &useMd, // Force no markdown conversion - legacy: true, // Use legacy mode for storage format + Options: rootOpts, + space: "DEV", + title: "Test Page", + file: mdFile, + bodyFormat: bodyFormatXHTML, } err = runCreate(context.Background(), opts) @@ -566,13 +564,11 @@ func TestRunCreate_Stdin_NoMarkdown_Legacy(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") rootOpts.SetAPIClient(client) - useMd := false opts := &createOptions{ - Options: rootOpts, - space: "DEV", - title: "Test Page", - markdown: &useMd, - legacy: true, + Options: rootOpts, + space: "DEV", + title: "Test Page", + bodyFormat: bodyFormatXHTML, } err := runCreate(context.Background(), opts) @@ -610,13 +606,11 @@ func TestRunCreate_StorageFlag_Stdin(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") rootOpts.SetAPIClient(client) - useMd := false opts := &createOptions{ - Options: rootOpts, - space: "DEV", - title: "Test Page", - storage: true, - markdown: &useMd, + Options: rootOpts, + space: "DEV", + title: "Test Page", + bodyFormat: bodyFormatXHTML, } err := runCreate(context.Background(), opts) @@ -660,14 +654,12 @@ func TestRunCreate_StorageFlag_File(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") rootOpts.SetAPIClient(client) - useMd := false opts := &createOptions{ - Options: rootOpts, - space: "DEV", - title: "Test Page", - file: htmlFile, - storage: true, - markdown: &useMd, + Options: rootOpts, + space: "DEV", + title: "Test Page", + file: htmlFile, + bodyFormat: bodyFormatXHTML, } err = runCreate(context.Background(), opts) @@ -881,7 +873,7 @@ func TestRunCreate_FileDash_Stdin_ADF(t *testing.T) { testutil.Contains(t, content, `"type":"strong"`) } -// "--file - --storage" pipes raw storage XHTML through unchanged. +// "--file - --body-format xhtml" pipes storage XHTML through unchanged. func TestRunCreate_FileDash_Stdin_Storage(t *testing.T) { t.Parallel() var receivedBody map[string]any @@ -893,14 +885,12 @@ func TestRunCreate_FileDash_Stdin_Storage(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") rootOpts.SetAPIClient(client) - useMd := false opts := &createOptions{ - Options: rootOpts, - space: "DEV", - title: "Test Page", - file: "-", - storage: true, - markdown: &useMd, + Options: rootOpts, + space: "DEV", + title: "Test Page", + file: "-", + bodyFormat: bodyFormatXHTML, } err := runCreate(context.Background(), opts) @@ -912,7 +902,7 @@ func TestRunCreate_FileDash_Stdin_Storage(t *testing.T) { testutil.Nil(t, bodyMap["atlas_doc_format"]) } -// "--file - --no-markdown" passes raw ADF JSON through to atlas_doc_format +// "--file - --body-format adf" passes ADF JSON through to atlas_doc_format // unconverted (the shape INT-425's create_page(format="adf") relies on). func TestRunCreate_FileDash_Stdin_NoMarkdown_ADF(t *testing.T) { t.Parallel() @@ -926,13 +916,12 @@ func TestRunCreate_FileDash_Stdin_NoMarkdown_ADF(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") rootOpts.SetAPIClient(client) - useMd := false opts := &createOptions{ - Options: rootOpts, - space: "DEV", - title: "Test Page", - file: "-", - markdown: &useMd, + Options: rootOpts, + space: "DEV", + title: "Test Page", + file: "-", + bodyFormat: bodyFormatADF, } err := runCreate(context.Background(), opts) diff --git a/tools/cfl/internal/cmd/page/edit.go b/tools/cfl/internal/cmd/page/edit.go index 7622f179..f86504ac 100644 --- a/tools/cfl/internal/cmd/page/edit.go +++ b/tools/cfl/internal/cmd/page/edit.go @@ -6,7 +6,6 @@ import ( "io" "os" "os/exec" - "path/filepath" "strings" "github.com/spf13/cobra" @@ -19,14 +18,14 @@ import ( type editOptions struct { *root.Options - pageID string - title string - file string - editor bool - markdown *bool // nil = auto-detect, true = force markdown, false = force storage format - legacy bool // Use legacy editor (storage format) instead of cloud editor (ADF) - storage bool // Use storage representation directly (implies --no-markdown) - parent string + pageID string + title string + file string + editor bool + bodyFormat string + bodyFormatExplicit bool + legacy bool + parent string } func newEditCmd(rootOpts *root.Options) *cobra.Command { @@ -37,20 +36,17 @@ func newEditCmd(rootOpts *root.Options) *cobra.Command { Short: "Edit an existing page", Long: `Edit an existing Confluence page. -By default, pages are updated using the cloud editor format (ADF). -Use --legacy to update pages in the legacy editor format. +Markdown input is converted to cloud editor format (ADF) by default. +Use --legacy with Markdown to update pages in storage XHTML instead. Content can be provided via: - --file flag to read from a file (use --file - to read from stdin) - Standard input (pipe content) - Interactive editor with --editor -Content format: -- Markdown is the default for stdin, editor, and .md files -- Use --no-markdown to provide raw Confluence format (XHTML for legacy, ADF JSON for cloud) -- Use --storage to provide raw Confluence storage format (XHTML) and send it directly - via the storage representation API, regardless of the page's editor type -- Files with .html/.xhtml extensions are treated as storage format`, +Content format is selected with --body-format markdown|adf|xhtml. +Omitting --body-format means Markdown. ADF and XHTML are validated or sent +without conversion.`, Example: ` # Edit a page in the editor with current content cfl page edit 12345 --editor @@ -63,8 +59,11 @@ Content format: # Update page content from stdin echo "# Updated Content" | cfl page edit 12345 - # Update from stdin via explicit --file - (e.g. piping HTML as storage) - echo "

Updated

" | cfl page edit 12345 --file - --storage + # Update from exact ADF JSON + cfl page edit 12345 --file content.json --body-format adf + + # Update from exact storage XHTML + echo "

Updated

" | cfl page edit 12345 --body-format xhtml # Update page title only cfl page edit 12345 --title "New Title" @@ -75,27 +74,25 @@ Content format: # Move page and update title cfl page edit 12345 --parent 67890 --title "New Title" - # Pipe raw Confluence storage format (XHTML) directly - echo "

Updated

" | cfl page edit 12345 --storage - # Extract, transform, and re-upload storage-format content - cfl page view 12345 --raw --content-only | \ - sed 's/old/new/g' | cfl page edit 12345 --storage`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - opts.pageID = args[0] - opts.storage, _ = cmd.Flags().GetBool("storage") - if opts.storage { - // --storage implies --no-markdown (input is raw XHTML) - useMd := false - opts.markdown = &useMd + cfl page view 12345 --body-format xhtml --content-only | \ + sed 's/old/new/g' | cfl page edit 12345 --body-format xhtml`, + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(1)(cmd, args); err != nil { + return err + } + format, err := resolveBodyFormat(opts.bodyFormat, cmd.Flags().Changed("body-format")) + if err != nil { + return err } - if cmd.Flags().Changed("no-markdown") { - noMd, _ := cmd.Flags().GetBool("no-markdown") - useMd := !noMd - opts.markdown = &useMd + if opts.legacy && format != bodyFormatMarkdown { + return fmt.Errorf("--legacy is only supported with --body-format markdown") } - opts.legacy, _ = cmd.Flags().GetBool("legacy") + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + opts.pageID = args[0] + opts.bodyFormatExplicit = cmd.Flags().Changed("body-format") return runEdit(cmd.Context(), opts) }, } @@ -104,14 +101,21 @@ Content format: cmd.Flags().StringVarP(&opts.file, "file", "f", "", "Read content from file") cmd.Flags().StringVarP(&opts.parent, "parent", "p", "", "Move page to new parent page ID") cmd.Flags().BoolVar(&opts.editor, "editor", false, "Open editor for content") - cmd.Flags().Bool("no-markdown", false, "Disable markdown conversion (use raw XHTML)") - cmd.Flags().Bool("storage", false, "Input is Confluence storage format (XHTML); sends via storage representation API") - cmd.Flags().Bool("legacy", false, "Edit page in legacy editor format (default: cloud editor)") + cmd.Flags().StringVar(&opts.bodyFormat, "body-format", bodyFormatMarkdown, "Input format: markdown, adf, or xhtml") + cmd.Flags().BoolVar(&opts.legacy, "legacy", false, "Edit page in legacy editor format (Markdown input only)") return cmd } func runEdit(ctx context.Context, opts *editOptions) error { + bodyFormat, err := resolveBodyFormat(opts.bodyFormat, opts.bodyFormatExplicit) + if err != nil { + return err + } + if opts.legacy && bodyFormat != bodyFormatMarkdown { + return fmt.Errorf("--legacy is only supported with --body-format markdown") + } + // Validate file exists before making any network calls so we fail // fast on bad input without needing config or API access. "-" means // stdin, which has no path to stat. @@ -121,22 +125,47 @@ func runEdit(ctx context.Context, opts *editOptions) error { } } - cfg, err := opts.Config() - if err != nil { - return err - } - if opts.title == "" && opts.parent == "" && !hasContentSource(opts.Options, opts.file, opts.editor) { return errMissingContentSource() } + hasStdinData := opts.Stdin != nil && opts.Stdin != os.Stdin + if !hasStdinData { + stat, _ := os.Stdin.Stat() + hasStdinData = (stat.Mode() & os.ModeCharDevice) == 0 + } + hasNewContent := opts.file != "" || opts.editor || hasStdinData + var newBody *api.Body + if hasNewContent && !opts.editor { + content, err := getEditContent(opts, nil, bodyFormat) + if err != nil { + return err + } + if strings.TrimSpace(content) == "" { + return fmt.Errorf("page content cannot be empty") + } + newBody, err = bodyForInput(content, bodyFormat, opts.legacy) + if err != nil { + return err + } + } + + cfg, err := opts.Config() + if err != nil { + return err + } client, err := opts.APIClient() if err != nil { return err } - existingPage, err := getPageWithBodyFallback(ctx, client, opts.pageID) + var existingPage *api.Page + if opts.editor { + existingPage, err = getPageWithBodyFormat(ctx, client, opts.pageID, bodyFormat) + } else { + existingPage, err = getPageWithBodyFallback(ctx, client, opts.pageID) + } if err != nil { return err } @@ -146,17 +175,8 @@ func runEdit(ctx context.Context, opts *editOptions) error { newTitle = existingPage.Title } - var newContent string - hasNewContent := false - - hasStdinData := opts.Stdin != nil && opts.Stdin != os.Stdin - if !hasStdinData { - stat, _ := os.Stdin.Stat() - hasStdinData = (stat.Mode() & os.ModeCharDevice) == 0 - } - - if opts.file != "" || opts.editor || hasStdinData { - content, isMarkdown, err := getEditContent(opts, existingPage) + if opts.editor { + content, err := getEditContent(opts, existingPage, bodyFormat) if err != nil { return err } @@ -165,11 +185,10 @@ func runEdit(ctx context.Context, opts *editOptions) error { return fmt.Errorf("page content cannot be empty") } - newContent, err = convertEditContent(content, isMarkdown, opts.storage || opts.legacy) + newBody, err = bodyForInput(content, bodyFormat, opts.legacy) if err != nil { return err } - hasNewContent = true } req := &api.UpdatePageRequest{ @@ -183,21 +202,7 @@ func runEdit(ctx context.Context, opts *editOptions) error { } if hasNewContent { - if opts.storage || opts.legacy { - req.Body = &api.Body{ - Storage: &api.BodyRepresentation{ - Representation: "storage", - Value: newContent, - }, - } - } else { - req.Body = &api.Body{ - AtlasDocFormat: &api.BodyRepresentation{ - Representation: "atlas_doc_format", - Value: newContent, - }, - } - } + req.Body = newBody } else { req.Body = existingPage.Body } @@ -216,108 +221,50 @@ func runEdit(ctx context.Context, opts *editOptions) error { return cflpresent.Emit(opts.Options, cflpresent.PagePresenter{}.PresentEdit(page, cfg.URL, opts.legacy && hasNewContent)) } -// convertEditContent converts content based on markdown flag and legacy mode. -func convertEditContent(content string, isMarkdown, legacy bool) (string, error) { - if legacy { - if isMarkdown { - converted, err := md.ToConfluenceStorage([]byte(content)) - if err != nil { - return "", fmt.Errorf("converting markdown: %w", err) - } - return converted, nil - } - return content, nil - } - - if isMarkdown { - adfContent, err := md.ToADF([]byte(content)) - if err != nil { - return "", fmt.Errorf("converting markdown to ADF: %w", err) - } - return adfContent, nil - } - return content, nil -} - -// getEditContent reads content for editing and returns (content, isMarkdown, error). -func getEditContent(opts *editOptions, existingPage *api.Page) (string, bool, error) { - useMarkdown := func(filename string) bool { - if opts.markdown != nil { - return *opts.markdown - } - if filename != "" { - ext := strings.ToLower(filepath.Ext(filename)) - switch ext { - case ".html", ".xhtml", ".htm": - return false - case ".md", ".markdown": - return true - } - } - return true - } - +func getEditContent(opts *editOptions, existingPage *api.Page, bodyFormat string) (string, error) { if opts.file == "-" { data, err := io.ReadAll(stdinReader(opts.Options)) if err != nil { - return "", false, fmt.Errorf("reading stdin: %w", err) + return "", fmt.Errorf("reading stdin: %w", err) } - return string(data), useMarkdown(""), nil + return string(data), nil } if opts.file != "" { data, err := os.ReadFile(opts.file) if err != nil { - return "", false, fmt.Errorf("reading file: %w", err) + return "", fmt.Errorf("reading file: %w", err) } - return string(data), useMarkdown(opts.file), nil + return string(data), nil } if opts.Stdin != nil && opts.Stdin != os.Stdin { data, err := io.ReadAll(opts.Stdin) if err != nil { - return "", false, fmt.Errorf("reading stdin: %w", err) + return "", fmt.Errorf("reading stdin: %w", err) } - return string(data), useMarkdown(""), nil + return string(data), nil } if hasPipedOSStdin(opts.Options) { data, err := io.ReadAll(os.Stdin) if err != nil { - return "", false, fmt.Errorf("reading stdin: %w", err) + return "", fmt.Errorf("reading stdin: %w", err) } - return string(data), useMarkdown(""), nil + return string(data), nil } if !opts.editor { - return "", false, errMissingContentSource() + return "", errMissingContentSource() } - isMarkdown := useMarkdown("") - content, err := openEditorForEdit(existingPage, isMarkdown) - return content, isMarkdown, err + return openEditorForEdit(existingPage, bodyFormat) } -func openEditorForEdit(existingPage *api.Page, isMarkdown bool) (string, error) { - ext := ".html" - if isMarkdown { - ext = ".md" - } - - existingContent := "" - if existingPage.Body != nil && existingPage.Body.Storage != nil { - existingContent = existingPage.Body.Storage.Value - } else if existingPage.Body != nil && existingPage.Body.AtlasDocFormat != nil { - // ADF-native page: convert to markdown for the editor. - markdown, err := md.FromADF(existingPage.Body.AtlasDocFormat.Value) - if err == nil { - existingContent = markdown - } - } - - editContent := existingContent - if isMarkdown && existingContent != "" { - editContent = "\n\n\n" + existingContent +func openEditorForEdit(existingPage *api.Page, bodyFormat string) (string, error) { + ext, editContent, err := editorContent(existingPage, bodyFormat) + if err != nil { + return "", err } tmpfile, err := os.CreateTemp("", "cfl-edit-*"+ext) @@ -353,10 +300,41 @@ func openEditorForEdit(existingPage *api.Page, isMarkdown bool) (string, error) return "", fmt.Errorf("reading edited content: %w", err) } - content := strings.TrimSpace(string(data)) - if content == "" { + content := string(data) + if strings.TrimSpace(content) == "" { return "", fmt.Errorf("no content provided") } - + if bodyFormat == bodyFormatMarkdown { + return strings.TrimSpace(content), nil + } return content, nil } + +func editorContent(page *api.Page, bodyFormat string) (string, string, error) { + switch bodyFormat { + case bodyFormatMarkdown: + if page.Body != nil && page.Body.Storage != nil { + content, err := md.FromConfluenceStorage(page.Body.Storage.Value) + if err != nil { + return "", "", fmt.Errorf("cannot produce markdown editor content: %w", err) + } + return ".md", content, nil + } + if page.Body != nil && page.Body.AtlasDocFormat != nil { + content, err := md.FromADF(page.Body.AtlasDocFormat.Value) + if err != nil { + return "", "", fmt.Errorf("cannot produce markdown editor content: %w", err) + } + return ".md", content, nil + } + case bodyFormatADF: + if page.Body != nil && page.Body.AtlasDocFormat != nil { + return ".json", page.Body.AtlasDocFormat.Value, nil + } + case bodyFormatXHTML: + if page.Body != nil && page.Body.Storage != nil { + return ".xhtml", page.Body.Storage.Value, nil + } + } + return "", "", fmt.Errorf("cannot produce %s editor content for this page", bodyFormat) +} diff --git a/tools/cfl/internal/cmd/page/edit_test.go b/tools/cfl/internal/cmd/page/edit_test.go index 87311d63..8e50b4c7 100644 --- a/tools/cfl/internal/cmd/page/edit_test.go +++ b/tools/cfl/internal/cmd/page/edit_test.go @@ -123,9 +123,8 @@ func TestRunEdit_TitleOnly(t *testing.T) { err := os.WriteFile(mdFile, []byte("

Keep this

"), 0600) testutil.RequireNoError(t, err) - useMd := false opts.file = mdFile - opts.markdown = &useMd + opts.bodyFormat = bodyFormatXHTML err = runEdit(context.Background(), opts) testutil.RequireNoError(t, err) @@ -326,11 +325,10 @@ func TestRunEdit_HTMLFile(t *testing.T) { rootOpts.SetAPIClient(client) rootOpts.Stdin = nil opts := &editOptions{ - Options: rootOpts, - pageID: "12345", - file: htmlFile, - legacy: true, // Use legacy mode for HTML files - + Options: rootOpts, + pageID: "12345", + file: htmlFile, + bodyFormat: bodyFormatXHTML, } err = runEdit(context.Background(), opts) @@ -381,14 +379,12 @@ func TestRunEdit_NoMarkdownFlag(t *testing.T) { rootOpts := newEditTestRootOptions() client := api.NewClient(server.URL, "test@example.com", "token") rootOpts.SetAPIClient(client) - useMd := false rootOpts.Stdin = nil opts := &editOptions{ - Options: rootOpts, - pageID: "12345", - file: mdFile, - markdown: &useMd, - legacy: true, // Use legacy mode for storage format + Options: rootOpts, + pageID: "12345", + file: mdFile, + bodyFormat: bodyFormatXHTML, } err = runEdit(context.Background(), opts) @@ -1305,12 +1301,10 @@ func TestRunEdit_StorageFlag_Stdin(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") rootOpts.SetAPIClient(client) rootOpts.Stdin = strings.NewReader(`

Content with

`) - useMd := false opts := &editOptions{ - Options: rootOpts, - pageID: "12345", - storage: true, - markdown: &useMd, + Options: rootOpts, + pageID: "12345", + bodyFormat: bodyFormatXHTML, } err := runEdit(context.Background(), opts) @@ -1368,13 +1362,11 @@ func TestRunEdit_StorageFlag_File(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") rootOpts.SetAPIClient(client) rootOpts.Stdin = nil - useMd := false opts := &editOptions{ - Options: rootOpts, - pageID: "12345", - file: htmlFile, - storage: true, - markdown: &useMd, + Options: rootOpts, + pageID: "12345", + file: htmlFile, + bodyFormat: bodyFormatXHTML, } err = runEdit(context.Background(), opts) @@ -1635,7 +1627,7 @@ func TestRunEdit_FileDash_Stdin_ADF(t *testing.T) { testutil.Contains(t, content, `"type":"strong"`) } -// "--file - --storage" pipes raw storage XHTML through unchanged. +// "--file - --body-format xhtml" pipes storage XHTML through unchanged. func TestRunEdit_FileDash_Stdin_Storage(t *testing.T) { t.Parallel() var receivedBody map[string]any @@ -1646,13 +1638,11 @@ func TestRunEdit_FileDash_Stdin_Storage(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") rootOpts.SetAPIClient(client) rootOpts.Stdin = strings.NewReader(`

Updated storage

`) - useMd := false opts := &editOptions{ - Options: rootOpts, - pageID: "12345", - file: "-", - storage: true, - markdown: &useMd, + Options: rootOpts, + pageID: "12345", + file: "-", + bodyFormat: bodyFormatXHTML, } err := runEdit(context.Background(), opts) @@ -1664,7 +1654,7 @@ func TestRunEdit_FileDash_Stdin_Storage(t *testing.T) { testutil.Nil(t, bodyMap["atlas_doc_format"]) } -// "--file - --no-markdown" passes raw ADF JSON through to atlas_doc_format +// "--file - --body-format adf" passes ADF JSON through to atlas_doc_format // unconverted (the shape INT-425's edit_page(format="adf") relies on). func TestRunEdit_FileDash_Stdin_NoMarkdown_ADF(t *testing.T) { t.Parallel() @@ -1677,12 +1667,11 @@ func TestRunEdit_FileDash_Stdin_NoMarkdown_ADF(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") rootOpts.SetAPIClient(client) rootOpts.Stdin = strings.NewReader(adf) - useMd := false opts := &editOptions{ - Options: rootOpts, - pageID: "12345", - file: "-", - markdown: &useMd, + Options: rootOpts, + pageID: "12345", + file: "-", + bodyFormat: bodyFormatADF, } err := runEdit(context.Background(), opts) @@ -1745,3 +1734,99 @@ func TestRunEdit_FileDash_Stdin_Legacy(t *testing.T) { testutil.Contains(t, content, "bold") testutil.Nil(t, bodyMap["atlas_doc_format"]) } + +func TestRunEdit_EditorBodyFormats(t *testing.T) { + script := filepath.Join(t.TempDir(), "editor") + err := os.WriteFile(script, []byte("#!/bin/sh\ncp \"$1\" \"$CFL_EDITOR_CAPTURE\"\nprintf '%s' \"$1\" > \"$CFL_EDITOR_PATH\"\n"), 0700) //nolint:gosec // executable test helper + testutil.RequireNoError(t, err) + t.Setenv("EDITOR", script) + + const ( + storageSource = "\n

Storage source

\n" + xhtmlFromADF = "\n

ADF as XHTML

\n" + adfFromStore = " \n" + `{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"text","text":"Storage as ADF"}]}]}` + "\n" + adfSource = " \n" + `{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"text","text":"ADF source"}]}]}` + "\n" + ) + + tests := []struct { + name, source, format, suffix, prefill string + }{ + {"storage markdown", "storage", bodyFormatMarkdown, ".md", "Storage source"}, + {"storage adf", "storage", bodyFormatADF, ".json", adfFromStore}, + {"storage xhtml", "storage", bodyFormatXHTML, ".xhtml", storageSource}, + {"adf markdown", "adf", bodyFormatMarkdown, ".md", "ADF source"}, + {"adf adf", "adf", bodyFormatADF, ".json", adfSource}, + {"adf xhtml", "adf", bodyFormatXHTML, ".xhtml", xhtmlFromADF}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + capture := filepath.Join(dir, "capture") + pathCapture := filepath.Join(dir, "path") + t.Setenv("CFL_EDITOR_CAPTURE", capture) + t.Setenv("CFL_EDITOR_PATH", pathCapture) + var update api.UpdatePageRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + format := r.URL.Query().Get("body-format") + body := &api.Body{} + switch format { + case "storage": + value := storageSource + if tc.source == "adf" { + value = xhtmlFromADF + if tc.format == bodyFormatMarkdown { + value = "" + } + } + body.Storage = &api.BodyRepresentation{Representation: "storage", Value: value} + case "atlas_doc_format": + value := adfFromStore + if tc.source == "adf" { + value = adfSource + } + body.AtlasDocFormat = &api.BodyRepresentation{Representation: "atlas_doc_format", Value: value} + default: + t.Fatalf("unexpected body-format %q", format) + } + _ = json.NewEncoder(w).Encode(api.Page{ID: "12345", Title: "Page", Version: &api.Version{Number: 1}, Body: body}) + case http.MethodPut: + testutil.RequireNoError(t, json.NewDecoder(r.Body).Decode(&update)) + _ = json.NewEncoder(w).Encode(api.Page{ID: "12345", Title: "Page", Version: &api.Version{Number: 2}}) + } + })) + defer server.Close() + + rootOpts := newEditTestRootOptions() + rootOpts.Stdin = nil + rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) + err := runEdit(context.Background(), &editOptions{ + Options: rootOpts, pageID: "12345", editor: true, bodyFormat: tc.format, + }) + testutil.RequireNoError(t, err) + + prefill, err := os.ReadFile(capture) //nolint:gosec // path is inside t.TempDir + testutil.RequireNoError(t, err) + if tc.format == bodyFormatMarkdown { + testutil.Equal(t, tc.prefill, strings.TrimSpace(string(prefill))) + } else { + testutil.Equal(t, tc.prefill, string(prefill)) + } + editorPath, err := os.ReadFile(pathCapture) //nolint:gosec // path is inside t.TempDir + testutil.RequireNoError(t, err) + testutil.Equal(t, tc.suffix, filepath.Ext(string(editorPath))) + switch tc.format { + case bodyFormatMarkdown: + testutil.NotNil(t, update.Body.AtlasDocFormat) + testutil.Contains(t, update.Body.AtlasDocFormat.Value, tc.prefill) + case bodyFormatADF: + testutil.Equal(t, tc.prefill, update.Body.AtlasDocFormat.Value) + case bodyFormatXHTML: + testutil.Equal(t, tc.prefill, update.Body.Storage.Value) + } + }) + } +} diff --git a/tools/cfl/internal/cmd/page/fetch.go b/tools/cfl/internal/cmd/page/fetch.go index 79a0b5d4..c16d4f00 100644 --- a/tools/cfl/internal/cmd/page/fetch.go +++ b/tools/cfl/internal/cmd/page/fetch.go @@ -2,10 +2,60 @@ package page import ( "context" + "fmt" "github.com/open-cli-collective/confluence-cli/api" ) +func getPageWithBodyFormat(ctx context.Context, client *api.Client, pageID, bodyFormat string) (*api.Page, error) { + if bodyFormat == bodyFormatMarkdown { + return getPageWithBodyFallback(ctx, client, pageID) + } + page, err := client.GetPage(ctx, pageID, &api.GetPageOptions{BodyFormat: apiBodyFormat(bodyFormat)}) + if err != nil { + return nil, err + } + if !hasBodyRepresentation(page, bodyFormat) { + return nil, fmt.Errorf("page does not provide the requested %s body representation", bodyFormat) + } + return page, nil +} + +func getPageVersionWithBodyFormat(ctx context.Context, client *api.Client, pageID string, version int, bodyFormat string) (*api.Page, error) { + if bodyFormat == bodyFormatMarkdown { + return getPageVersionWithBodyFallback(ctx, client, pageID, version) + } + location, err := client.LocatePageVersion(ctx, pageID, version) + if err != nil { + return nil, err + } + page, err := client.GetLocatedPageVersion(ctx, pageID, location, apiBodyFormat(bodyFormat)) + if err != nil { + return nil, err + } + if !hasBodyRepresentation(page, bodyFormat) { + return nil, fmt.Errorf("page version %d does not provide the requested %s body representation", version, bodyFormat) + } + return page, nil +} + +func apiBodyFormat(bodyFormat string) string { + if bodyFormat == bodyFormatADF { + return "atlas_doc_format" + } + return "storage" +} + +func hasBodyRepresentation(page *api.Page, bodyFormat string) bool { + if page == nil || page.Body == nil { + return false + } + if bodyFormat == bodyFormatADF { + return page.Body.AtlasDocFormat != nil + } + return page.Body.Storage != nil +} + // getPageWithBodyFallback fetches a page with body content, falling back to // atlas_doc_format if storage format returns empty content. This handles // ADF-native pages where the server-side ADF→XHTML conversion may fail diff --git a/tools/cfl/internal/cmd/page/fetch_test.go b/tools/cfl/internal/cmd/page/fetch_test.go index 16f8d855..7ccb20b5 100644 --- a/tools/cfl/internal/cmd/page/fetch_test.go +++ b/tools/cfl/internal/cmd/page/fetch_test.go @@ -140,6 +140,19 @@ func TestGetPageWithBodyFallback_GetPageError(t *testing.T) { testutil.RequireError(t, err) } +func TestGetPageWithBodyFormat_UnavailableRepresentation(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + testutil.Equal(t, "atlas_doc_format", r.URL.Query().Get("body-format")) + _, _ = w.Write([]byte(`{"id":"12345","body":{}}`)) + })) + defer server.Close() + + _, err := getPageWithBodyFormat(context.Background(), api.NewClient(server.URL, "test@example.com", "token"), "12345", bodyFormatADF) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "does not provide the requested adf") +} + func TestGetPageWithBodyFallback_ADFFallbackFails_GracefulDegradation(t *testing.T) { t.Parallel() callCount := 0 @@ -219,6 +232,28 @@ func TestGetPageVersionWithBodyFallback_StorageEmpty_FallsBackToADF(t *testing.T testutil.True(t, hasADFContent(page)) } +func TestGetPageVersionWithBodyFormat_ADF(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v2/pages/12345": + _, _ = w.Write([]byte(`{"id":"12345","title":"Page","version":{"number":2}}`)) + case strings.Contains(r.URL.Path, "/versions") && r.URL.Query().Get("body-format") == "": + _, _ = w.Write([]byte(`{"results":[{"number":2}]}`)) + case strings.Contains(r.URL.Path, "/versions"): + testutil.Equal(t, "atlas_doc_format", r.URL.Query().Get("body-format")) + _, _ = w.Write([]byte(`{"results":[{"number":2,"page":{"id":"12345","body":{"atlas_doc_format":{"representation":"atlas_doc_format","value":"{\"type\":\"doc\",\"version\":1,\"content\":[]}"}}}}]}`)) + default: + t.Fatalf("unexpected request: %s?%s", r.URL.Path, r.URL.RawQuery) + } + })) + defer server.Close() + + page, err := getPageVersionWithBodyFormat(context.Background(), api.NewClient(server.URL, "test@example.com", "token"), "12345", 2, bodyFormatADF) + testutil.RequireNoError(t, err) + testutil.Equal(t, `{"type":"doc","version":1,"content":[]}`, page.Body.AtlasDocFormat.Value) +} + func TestHasStorageContent(t *testing.T) { t.Parallel() tests := []struct { diff --git a/tools/cfl/internal/cmd/page/view.go b/tools/cfl/internal/cmd/page/view.go index 86916b1e..9a05ef5b 100644 --- a/tools/cfl/internal/cmd/page/view.go +++ b/tools/cfl/internal/cmd/page/view.go @@ -21,12 +21,13 @@ const maxViewChars = pageview.MaxChars type viewOptions struct { *root.Options - raw bool - web bool - noTruncate bool - showMacros bool - contentOnly bool - version int + bodyFormat string + bodyFormatExplicit bool + web bool + noTruncate bool + showMacros bool + contentOnly bool + version int } func newViewCmd(rootOpts *root.Options) *cobra.Command { @@ -37,8 +38,9 @@ func newViewCmd(rootOpts *root.Options) *cobra.Command { Short: "View a page", Long: `View a Confluence page content. -The page body is fetched in storage format (XHTML) and converted to -markdown for display. Use --raw to see the original storage format. +The page body is displayed as Markdown by default. Use --body-format adf +for exact Atlassian Document Format JSON or --body-format xhtml for exact +Confluence storage XHTML. By default, output is truncated to 5000 characters for concise display. Use --no-truncate to show the complete page content without truncation. @@ -49,8 +51,11 @@ The --content-only flag implies --no-truncate since it is intended for piping.`, # View full content without truncation cfl page view 12345 --no-truncate - # View raw storage format (XHTML) - cfl page view 12345 --raw + # View exact storage format (XHTML) + cfl page view 12345 --body-format xhtml + + # View exact ADF JSON + cfl page view 12345 --body-format adf # View a specific historical version cfl page view 12345 --version 7 @@ -58,18 +63,31 @@ The --content-only flag implies --no-truncate since it is intended for piping.`, # Open in browser cfl page view 12345 --web - # Pipe raw content to edit (lossless roundtrip) - cfl page view 12345 --raw --content-only | cfl page edit 12345 --no-markdown --legacy + # Pipe XHTML content to edit (lossless roundtrip) + cfl page view 12345 --body-format xhtml --content-only | cfl page edit 12345 --body-format xhtml # Pipe markdown content to edit cfl page view 12345 --content-only | cfl page edit 12345 --legacy`, - Args: cobra.ExactArgs(1), + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.ExactArgs(1)(cmd, args); err != nil { + return err + } + format, err := resolveBodyFormat(opts.bodyFormat, cmd.Flags().Changed("body-format")) + if err != nil { + return err + } + if opts.showMacros && format != bodyFormatMarkdown { + return fmt.Errorf("--show-macros is only supported with --body-format markdown") + } + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { + opts.bodyFormatExplicit = cmd.Flags().Changed("body-format") return runView(cmd.Context(), args[0], opts) }, } - cmd.Flags().BoolVar(&opts.raw, "raw", false, "Show raw Confluence storage format (XHTML) instead of markdown") + cmd.Flags().StringVar(&opts.bodyFormat, "body-format", bodyFormatMarkdown, "Body format: markdown, adf, or xhtml") cmd.Flags().BoolVarP(&opts.web, "web", "w", false, "Open in browser instead of displaying") cmd.Flags().BoolVar(&opts.noTruncate, "no-truncate", false, "Show full content without truncation") cmd.Flags().BoolVar(&opts.showMacros, "show-macros", false, "Show Confluence macro placeholders (e.g., [TOC]) instead of stripping them") @@ -80,6 +98,13 @@ The --content-only flag implies --no-truncate since it is intended for piping.`, } func runView(ctx context.Context, pageID string, opts *viewOptions) error { + bodyFormat, err := resolveBodyFormat(opts.bodyFormat, opts.bodyFormatExplicit) + if err != nil { + return err + } + if opts.showMacros && bodyFormat != bodyFormatMarkdown { + return fmt.Errorf("--show-macros is only supported with --body-format markdown") + } if opts.contentOnly { if opts.web { return fmt.Errorf("--content-only is incompatible with --web") @@ -114,9 +139,9 @@ func runView(ctx context.Context, pageID string, opts *viewOptions) error { var page *api.Page if opts.version > 0 { - page, err = getPageVersionWithBodyFallback(ctx, client, pageID, opts.version) + page, err = getPageVersionWithBodyFormat(ctx, client, pageID, opts.version, bodyFormat) } else { - page, err = getPageWithBodyFallback(ctx, client, pageID) + page, err = getPageWithBodyFormat(ctx, client, pageID, bodyFormat) } if err != nil { return err @@ -132,12 +157,15 @@ func runView(ctx context.Context, pageID string, opts *viewOptions) error { // Graceful fallback: if GetSpace fails, we just won't show the key } - proj := pageview.Project(page, spaceKey, pageview.Options{ - Raw: opts.raw, + proj, err := pageview.Project(page, spaceKey, pageview.Options{ + BodyFormat: bodyFormat, NoTruncate: opts.noTruncate, ShowMacros: opts.showMacros, ContentOnly: opts.contentOnly, }) + if err != nil { + return cflpresent.EmitError(opts.Options, err) + } return cflpresent.Emit(opts.Options, cflpresent.PagePresenter{}.PresentView(proj)) } diff --git a/tools/cfl/internal/cmd/page/view_test.go b/tools/cfl/internal/cmd/page/view_test.go index 9b0ba3e1..1d027433 100644 --- a/tools/cfl/internal/cmd/page/view_test.go +++ b/tools/cfl/internal/cmd/page/view_test.go @@ -155,8 +155,8 @@ func TestRunView_ExactOutput_Raw(t *testing.T) { rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) err := runView(context.Background(), "12345", &viewOptions{ - Options: rootOpts, - raw: true, + Options: rootOpts, + bodyFormat: bodyFormatXHTML, }) testutil.RequireNoError(t, err) @@ -185,7 +185,7 @@ func TestRunView_ExactOutput_RawContentOnly_NoTruncate(t *testing.T) { err := runView(context.Background(), "12345", &viewOptions{ Options: rootOpts, - raw: true, + bodyFormat: bodyFormatXHTML, contentOnly: true, noTruncate: true, }) @@ -267,10 +267,10 @@ func TestRunView_ExactOutput_ConversionFallback(t *testing.T) { Options: rootOpts, contentOnly: true, }) - testutil.RequireNoError(t, err) - - testutil.Equal(t, "{not-json\n", rootOpts.Stdout.(*bytes.Buffer).String()) - testutil.Equal(t, "(Failed to convert ADF to markdown, showing raw ADF)\n", rootOpts.Stderr.(*bytes.Buffer).String()) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "--body-format adf") + testutil.Equal(t, "", rootOpts.Stdout.(*bytes.Buffer).String()) + testutil.Contains(t, rootOpts.Stderr.(*bytes.Buffer).String(), "--body-format adf") } func TestRunView_ExactOutput_StorageConversionFallback_Default(t *testing.T) { @@ -306,10 +306,10 @@ func TestRunView_ExactOutput_StorageConversionFallback_Default(t *testing.T) { rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) err := runView(context.Background(), "12345", &viewOptions{Options: rootOpts}) - testutil.RequireNoError(t, err) - - testutil.Equal(t, "Title: Broken Storage Page\nID: 12345\nSpace: TEST (ID: 98765)\nVersion: 7\n\n

Fallback HTML

\n", rootOpts.Stdout.(*bytes.Buffer).String()) - testutil.Equal(t, "(Failed to convert to markdown, showing raw HTML)\n", rootOpts.Stderr.(*bytes.Buffer).String()) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "--body-format xhtml") + testutil.Equal(t, "", rootOpts.Stdout.(*bytes.Buffer).String()) + testutil.Contains(t, rootOpts.Stderr.(*bytes.Buffer).String(), "--body-format xhtml") } func TestRunView_RawFormat(t *testing.T) { @@ -331,8 +331,8 @@ func TestRunView_RawFormat(t *testing.T) { rootOpts.SetAPIClient(client) opts := &viewOptions{ - Options: rootOpts, - raw: true, + Options: rootOpts, + bodyFormat: bodyFormatXHTML, } err := runView(context.Background(), "12345", opts) @@ -492,7 +492,7 @@ func TestRunView_ContentOnly_Raw(t *testing.T) { opts := &viewOptions{ Options: rootOpts, contentOnly: true, - raw: true, + bodyFormat: bodyFormatXHTML, } err := runView(context.Background(), "12345", opts) @@ -590,9 +590,9 @@ func TestRunView_VersionRaw(t *testing.T) { rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) opts := &viewOptions{ - Options: rootOpts, - version: 2, - raw: true, + Options: rootOpts, + version: 2, + bodyFormat: bodyFormatXHTML, } err := runView(context.Background(), "12345", opts) @@ -642,9 +642,9 @@ func TestRunView_VersionTruncatesByDefault(t *testing.T) { rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) opts := &viewOptions{ - Options: rootOpts, - version: 2, - raw: true, + Options: rootOpts, + version: 2, + bodyFormat: bodyFormatXHTML, } err := runView(context.Background(), "12345", opts) @@ -663,7 +663,7 @@ func TestRunView_VersionNoTruncate(t *testing.T) { opts := &viewOptions{ Options: rootOpts, version: 2, - raw: true, + bodyFormat: bodyFormatXHTML, noTruncate: true, } @@ -985,7 +985,7 @@ func TestRunView_ADFPage_RawFormat(t *testing.T) { rootOpts := newViewTestRootOptions() client := api.NewClient(server.URL, "test@example.com", "token") rootOpts.SetAPIClient(client) - opts := &viewOptions{Options: rootOpts, raw: true} + opts := &viewOptions{Options: rootOpts, bodyFormat: bodyFormatADF} err := runView(context.Background(), "12345", opts) testutil.RequireNoError(t, err) diff --git a/tools/cfl/internal/pageview/projection.go b/tools/cfl/internal/pageview/projection.go index 9d30b11d..7290ca10 100644 --- a/tools/cfl/internal/pageview/projection.go +++ b/tools/cfl/internal/pageview/projection.go @@ -1,6 +1,8 @@ package pageview import ( + "fmt" + "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/pkg/md" ) @@ -10,7 +12,7 @@ const MaxChars = 5000 // Options controls page-view body projection. type Options struct { - Raw bool + BodyFormat string NoTruncate bool ShowMacros bool ContentOnly bool @@ -22,17 +24,8 @@ type BodyKind int const ( BodyKindNone BodyKind = iota BodyKindMarkdown - BodyKindStorageRaw - BodyKindADFRaw -) - -// FallbackKind identifies whether conversion fell back to a raw/source-faithful body. -type FallbackKind int - -const ( - FallbackNone FallbackKind = iota - FallbackStorageRaw - FallbackADFRaw + BodyKindXHTML + BodyKindADF ) // Projection is the presenter-facing view model for page view output. @@ -46,7 +39,6 @@ type Projection struct { ContentOnly bool Body string BodyKind BodyKind - Fallback FallbackKind HasContent bool Truncated bool } @@ -57,8 +49,7 @@ var ( ) // OverrideConvertersForTest swaps the storage/ADF conversion hooks and returns -// a restore function. It exists so higher-level command tests can force the -// fallback branches without depending on fragile converter internals. +// a restore function. It exists so command tests can force conversion errors. func OverrideConvertersForTest( storage func(string, md.ConvertOptions) (string, error), adf func(string) (string, error), @@ -79,7 +70,7 @@ func OverrideConvertersForTest( // Project builds the presenter-facing page-view projection from API data and // command mode flags. -func Project(page *api.Page, spaceKey string, opts Options) Projection { +func Project(page *api.Page, spaceKey string, opts Options) (Projection, error) { proj := Projection{ Title: page.Title, ID: page.ID, @@ -92,48 +83,61 @@ func Project(page *api.Page, spaceKey string, opts Options) Projection { proj.HasVersion = true } - switch { - case hasStorageContent(page): - proj.Body, proj.BodyKind, proj.Fallback, proj.Truncated = projectStorageBody(page.Body.Storage.Value, opts) - proj.HasContent = true - case hasADFContent(page): - proj.Body, proj.BodyKind, proj.Fallback, proj.Truncated = projectADFBody(page.Body.AtlasDocFormat.Value, opts) - proj.HasContent = true + switch opts.BodyFormat { + case "adf": + if page.Body != nil && page.Body.AtlasDocFormat != nil { + proj.Body, proj.Truncated = TruncateContent(page.Body.AtlasDocFormat.Value, opts) + proj.BodyKind = BodyKindADF + proj.HasContent = page.Body.AtlasDocFormat.Value != "" + } + case "xhtml": + if page.Body != nil && page.Body.Storage != nil { + proj.Body, proj.Truncated = TruncateContent(page.Body.Storage.Value, opts) + proj.BodyKind = BodyKindXHTML + proj.HasContent = page.Body.Storage.Value != "" + } + case "", "markdown": + switch { + case hasStorageContent(page): + body, truncated, err := projectStorageBody(page.Body.Storage.Value, opts) + if err != nil { + return Projection{}, err + } + proj.Body, proj.Truncated = body, truncated + proj.BodyKind = BodyKindMarkdown + proj.HasContent = true + case hasADFContent(page): + body, truncated, err := projectADFBody(page.Body.AtlasDocFormat.Value, opts) + if err != nil { + return Projection{}, err + } + proj.Body, proj.Truncated = body, truncated + proj.BodyKind = BodyKindMarkdown + proj.HasContent = true + } default: - proj.HasContent = false + return Projection{}, fmt.Errorf("unsupported body format %q", opts.BodyFormat) } - return proj + return proj, nil } -func projectStorageBody(content string, opts Options) (body string, kind BodyKind, fallback FallbackKind, truncated bool) { - if opts.Raw { - body, truncated = TruncateContent(content, opts) - return body, BodyKindStorageRaw, FallbackNone, truncated - } - +func projectStorageBody(content string, opts Options) (body string, truncated bool, err error) { markdown, err := fromStorage(content, md.ConvertOptions{ShowMacros: opts.ShowMacros}) if err != nil { - body, truncated = TruncateContent(content, opts) - return body, BodyKindStorageRaw, FallbackStorageRaw, truncated + return "", false, fmt.Errorf("failed to convert XHTML to markdown; rerun with --body-format xhtml or --body-format adf: %w", err) } body, truncated = TruncateContent(markdown, opts) - return body, BodyKindMarkdown, FallbackNone, truncated + return body, truncated, nil } -func projectADFBody(content string, opts Options) (body string, kind BodyKind, fallback FallbackKind, truncated bool) { - if opts.Raw { - body, truncated = TruncateContent(content, opts) - return body, BodyKindADFRaw, FallbackNone, truncated - } - +func projectADFBody(content string, opts Options) (body string, truncated bool, err error) { markdown, err := fromADF(content) if err != nil { - body, truncated = TruncateContent(content, opts) - return body, BodyKindADFRaw, FallbackADFRaw, truncated + return "", false, fmt.Errorf("failed to convert ADF to markdown; rerun with --body-format adf or --body-format xhtml: %w", err) } body, truncated = TruncateContent(markdown, opts) - return body, BodyKindMarkdown, FallbackNone, truncated + return body, truncated, nil } // TruncateContent truncates content if it exceeds the character limit and diff --git a/tools/cfl/internal/pageview/projection_test.go b/tools/cfl/internal/pageview/projection_test.go index e4ba17fd..6d1d0662 100644 --- a/tools/cfl/internal/pageview/projection_test.go +++ b/tools/cfl/internal/pageview/projection_test.go @@ -11,123 +11,66 @@ import ( "github.com/open-cli-collective/confluence-cli/pkg/md" ) -func TestProject_DefaultStorageMarkdown(t *testing.T) { +func TestProject_BodyFormats(t *testing.T) { t.Parallel() - page := &api.Page{ - ID: "12345", - Title: "Test Page", - SpaceID: "98765", - Version: &api.Version{Number: 3}, + ID: "12345", Title: "Test Page", SpaceID: "98765", Version: &api.Version{Number: 3}, Body: &api.Body{ - Storage: &api.BodyRepresentation{Value: "

Hello World

"}, + Storage: &api.BodyRepresentation{Value: "

Hello

"}, + AtlasDocFormat: &api.BodyRepresentation{Value: `{"type":"doc","version":1,"content":[]}`}, }, } - expectedBody, err := md.FromConfluenceStorageWithOptions( - "

Hello World

", - md.ConvertOptions{}, - ) + markdown, err := Project(page, "TEST", Options{BodyFormat: "markdown"}) testutil.RequireNoError(t, err) + testutil.Equal(t, "Hello", markdown.Body) + testutil.Equal(t, BodyKindMarkdown, markdown.BodyKind) + omitted, err := Project(page, "TEST", Options{}) + testutil.RequireNoError(t, err) + testutil.Equal(t, markdown, omitted) - proj := Project(page, "TEST", Options{}) - - testutil.Equal(t, Projection{ - Title: "Test Page", - ID: "12345", - SpaceKey: "TEST", - SpaceID: "98765", - Version: 3, - HasVersion: true, - ContentOnly: false, - Body: expectedBody, - BodyKind: BodyKindMarkdown, - Fallback: FallbackNone, - HasContent: true, - Truncated: false, - }, proj) -} - -func TestProject_ContentOnlyRawStorage(t *testing.T) { - t.Parallel() - - proj := Project(&api.Page{ - Body: &api.Body{ - Storage: &api.BodyRepresentation{Value: "

Raw HTML

"}, - }, - }, "", Options{Raw: true, ContentOnly: true}) - - testutil.Equal(t, "

Raw HTML

", proj.Body) - testutil.True(t, proj.ContentOnly) - testutil.Equal(t, BodyKindStorageRaw, proj.BodyKind) - testutil.Equal(t, FallbackNone, proj.Fallback) - testutil.True(t, proj.HasContent) -} - -func TestProject_ADFConversionFallback(t *testing.T) { - t.Parallel() - - proj := Project(&api.Page{ - Body: &api.Body{ - AtlasDocFormat: &api.BodyRepresentation{Value: "{not-json"}, - }, - }, "", Options{}) + xhtml, err := Project(page, "TEST", Options{BodyFormat: "xhtml", ContentOnly: true}) + testutil.RequireNoError(t, err) + testutil.Equal(t, "

Hello

", xhtml.Body) + testutil.Equal(t, BodyKindXHTML, xhtml.BodyKind) - testutil.Equal(t, "{not-json", proj.Body) - testutil.Equal(t, BodyKindADFRaw, proj.BodyKind) - testutil.Equal(t, FallbackADFRaw, proj.Fallback) - testutil.True(t, proj.HasContent) + adf, err := Project(page, "TEST", Options{BodyFormat: "adf", ContentOnly: true}) + testutil.RequireNoError(t, err) + testutil.Equal(t, `{"type":"doc","version":1,"content":[]}`, adf.Body) + testutil.Equal(t, BodyKindADF, adf.BodyKind) } -func TestProject_StorageConversionFallback(t *testing.T) { +func TestProject_MarkdownConversionFailsClosed(t *testing.T) { restore := OverrideConvertersForTest(func(string, md.ConvertOptions) (string, error) { return "", errors.New("boom") }, nil) defer restore() - proj := Project(&api.Page{ - Body: &api.Body{ - Storage: &api.BodyRepresentation{Value: "

Raw fallback

"}, - }, - }, "", Options{}) - - testutil.Equal(t, "

Raw fallback

", proj.Body) - testutil.Equal(t, BodyKindStorageRaw, proj.BodyKind) - testutil.Equal(t, FallbackStorageRaw, proj.Fallback) - testutil.True(t, proj.HasContent) + proj, err := Project(&api.Page{Body: &api.Body{ + Storage: &api.BodyRepresentation{Value: "

must not leak

"}, + }}, "", Options{BodyFormat: "markdown"}) + testutil.RequireError(t, err) + testutil.Equal(t, Projection{}, proj) + testutil.Contains(t, err.Error(), "--body-format xhtml") } func TestProject_EmptyContent(t *testing.T) { t.Parallel() - - proj := Project(&api.Page{ - ID: "12345", - Title: "Empty Page", - }, "", Options{}) - + proj, err := Project(&api.Page{ID: "12345", Title: "Empty Page"}, "", Options{}) + testutil.RequireNoError(t, err) testutil.Equal(t, "", proj.Body) testutil.Equal(t, BodyKindNone, proj.BodyKind) - testutil.Equal(t, FallbackNone, proj.Fallback) testutil.False(t, proj.HasContent) } func TestTruncateContent(t *testing.T) { t.Parallel() - - short, shortTruncated := TruncateContent("short", Options{}) - testutil.Equal(t, "short", short) - testutil.False(t, shortTruncated) - long := strings.Repeat("x", MaxChars+10) truncated, wasTruncated := TruncateContent(long, Options{}) testutil.Equal(t, strings.Repeat("x", MaxChars), truncated) testutil.True(t, wasTruncated) - full, fullTruncated := TruncateContent(long, Options{NoTruncate: true}) + full, fullTruncated := TruncateContent(long, Options{ContentOnly: true}) testutil.Equal(t, long, full) testutil.False(t, fullTruncated) - - contentOnly, contentOnlyTruncated := TruncateContent(long, Options{ContentOnly: true}) - testutil.Equal(t, long, contentOnly) - testutil.False(t, contentOnlyTruncated) } diff --git a/tools/cfl/internal/present/README.md b/tools/cfl/internal/present/README.md index 78787c86..6a5ef73a 100644 --- a/tools/cfl/internal/present/README.md +++ b/tools/cfl/internal/present/README.md @@ -109,7 +109,7 @@ These exceptions are allowed when deliberate and tested: Exceptions should stay small and named in code review. They must not become a general escape hatch for command-local formatting. -Source-faithful modes such as `page view --raw` and `--content-only` are not +Source-faithful modes such as `page view --body-format xhtml` and `--content-only` are not presenter-boundary exceptions. They are presenter/projection-owned output modes whose content selection is intentional. diff --git a/tools/cfl/internal/present/detail.go b/tools/cfl/internal/present/detail.go index 5414b3e8..ecef8ff0 100644 --- a/tools/cfl/internal/present/detail.go +++ b/tools/cfl/internal/present/detail.go @@ -99,10 +99,6 @@ func (PagePresenter) PresentView(proj pageview.Projection) *sharedpresent.Output sections = append(sections, &sharedpresent.DetailSection{Fields: fields}) } - if advisory := pageViewAdvisory(proj.Fallback); advisory != "" { - sections = append(sections, stderrInfo(advisory)) - } - body := pageViewBody(proj) if !proj.ContentOnly { sections = append(sections, stdoutInfo("")) @@ -124,19 +120,6 @@ func pageViewBody(proj pageview.Projection) string { return body } -func pageViewAdvisory(fallback pageview.FallbackKind) string { - switch fallback { - case pageview.FallbackNone: - return "" - case pageview.FallbackStorageRaw: - return "(Failed to convert to markdown, showing raw HTML)" - case pageview.FallbackADFRaw: - return "(Failed to convert ADF to markdown, showing raw ADF)" - default: - return "" - } -} - func formatValueWithSource(v cflconfig.ShowValue) string { if v.Value == "" { return fmt.Sprintf("(source: %s)", v.Source) diff --git a/tools/cfl/internal/present/detail_test.go b/tools/cfl/internal/present/detail_test.go index 21389d1e..8ee002d5 100644 --- a/tools/cfl/internal/present/detail_test.go +++ b/tools/cfl/internal/present/detail_test.go @@ -136,22 +136,17 @@ func TestPagePresenter_PresentView_Default(t *testing.T) { testutil.Equal(t, "Hello world", body.Message) } -func TestPagePresenter_PresentView_ContentOnlyWithAdvisory(t *testing.T) { +func TestPagePresenter_PresentView_ContentOnlyXHTML(t *testing.T) { t.Parallel() model := PagePresenter{}.PresentView(pageview.Projection{ ContentOnly: true, Body: "

Raw

", - BodyKind: pageview.BodyKindStorageRaw, - Fallback: pageview.FallbackStorageRaw, + BodyKind: pageview.BodyKindXHTML, HasContent: true, }) - msg := requireMessageSection(t, model, 0) - testutil.Equal(t, sharedpresent.StreamStderr, msg.Stream) - testutil.Equal(t, "(Failed to convert to markdown, showing raw HTML)", msg.Message) - - body := requireMessageSection(t, model, 1) + body := requireMessageSection(t, model, 0) testutil.Equal(t, sharedpresent.StreamStdout, body.Stream) testutil.Equal(t, "

Raw

", body.Message) } diff --git a/tools/cfl/internal/present/emit.go b/tools/cfl/internal/present/emit.go index a868b15c..feb1412c 100644 --- a/tools/cfl/internal/present/emit.go +++ b/tools/cfl/internal/present/emit.go @@ -1,6 +1,7 @@ package present import ( + "errors" "fmt" sharedpresent "github.com/open-cli-collective/atlassian-go/present" @@ -8,6 +9,15 @@ import ( "github.com/open-cli-collective/confluence-cli/internal/cmd/root" ) +// ErrEmitted marks an error whose user-facing text was already written. +var ErrEmitted = errors.New("error already emitted") + +type emittedError struct{ err error } + +func (e emittedError) Error() string { return e.err.Error() } +func (e emittedError) Unwrap() error { return e.err } +func (e emittedError) Is(target error) bool { return target == ErrEmitted } + // Emit renders a presentation model and writes the split streams to the root // writers using cfl's root-derived render style. func Emit(opts *root.Options, model *sharedpresent.OutputModel) error { @@ -16,3 +26,10 @@ func Emit(opts *root.Options, model *sharedpresent.OutputModel) error { _, _ = fmt.Fprint(opts.Stderr, out.Stderr) return nil } + +// EmitError writes an error through the presenter stream model and marks it so +// the process entrypoint does not print it a second time. +func EmitError(opts *root.Options, err error) error { + _ = Emit(opts, &sharedpresent.OutputModel{Sections: []sharedpresent.Section{stderrInfo(err.Error())}}) + return emittedError{err: err} +} diff --git a/tools/cfl/scripts/roundtrip-test.sh b/tools/cfl/scripts/roundtrip-test.sh index 179eaf67..9eb4ffa5 100755 --- a/tools/cfl/scripts/roundtrip-test.sh +++ b/tools/cfl/scripts/roundtrip-test.sh @@ -139,7 +139,7 @@ process_page() { # Step 1: Fetch page and check format # Capture raw content first to distinguish fetch failures from ADF pages local raw_content - if ! raw_content=$(cfl page view "$id" --raw --content-only 2>&1); then + if ! raw_content=$(cfl page view "$id" --body-format xhtml --content-only 2>&1); then echo "[$id] FAIL: Could not fetch page: $raw_content" FAIL=$((FAIL + 1)) return 1 @@ -152,7 +152,7 @@ process_page() { return 1 fi - # Check if storage format (XHTML starts with <) vs ADF (JSON starts with {) + # Storage XHTML starts with markup; skip pages for which storage is unavailable. local first_char="${raw_content:0:1}" if [[ "$first_char" != "<" ]]; then echo "[$id] SKIP: ADF-backed (not storage format)" @@ -194,7 +194,7 @@ process_page() { CLEANUP_IDS+=("$new_id") # Step 5: Capture roundtripped XHTML - if ! cfl page view "$new_id" --raw --content-only > "$after_file" 2>/dev/null; then + if ! cfl page view "$new_id" --body-format xhtml --content-only > "$after_file" 2>/dev/null; then echo "[$id] FAIL: Could not fetch roundtripped page" FAIL=$((FAIL + 1)) return 1 From a1c76779d6e86d470b43006efb7cfb7a1efe3df3 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Fri, 17 Jul 2026 07:38:34 -0400 Subject: [PATCH 03/12] feat(cfl)!: explicit search scope, strict limits, implement page view --full (#455) --- README.md | 3 +- docs/ARTIFACT_CONTRACT.md | 5 +- skills/Confluence/CliReference.md | 18 ++- skills/Confluence/SKILL.md | 8 +- .../Confluence/Workflows/ManageAttachments.md | 2 +- skills/Confluence/Workflows/SearchPages.md | 18 ++- skills/Confluence/Workflows/ViewPage.md | 3 + tools/cfl/CHANGELOG.md | 1 + tools/cfl/README.md | 14 +- tools/cfl/api/pages.go | 9 ++ tools/cfl/api/pages_test.go | 6 + tools/cfl/api/search.go | 3 + tools/cfl/api/search_test.go | 25 +++- tools/cfl/internal/cmd/OUTPUT_SPEC.md | 18 ++- tools/cfl/internal/cmd/attachment/list.go | 19 ++- .../cfl/internal/cmd/attachment/list_test.go | 49 +++++++ tools/cfl/internal/cmd/page/view.go | 21 ++- tools/cfl/internal/cmd/page/view_test.go | 89 +++++++++++- tools/cfl/internal/cmd/root/root.go | 14 +- tools/cfl/internal/cmd/root/root_test.go | 13 ++ tools/cfl/internal/cmd/search/search.go | 73 +++++----- tools/cfl/internal/cmd/search/search_test.go | 136 +++++++++++------- tools/cfl/internal/pageview/projection.go | 11 ++ tools/cfl/internal/present/detail.go | 7 + 24 files changed, 444 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index 176b75a2..a124b5a0 100644 --- a/README.md +++ b/README.md @@ -363,7 +363,8 @@ cfl page create --space DEV --title "New Page" --file content.md cfl page edit 123456 # Search content -cfl search "my search query" +cfl search "my search query" # Global search +cfl search "my search query" --space DEV # Explicit space scope cfl search --cql "space = DEV AND type = page" # Copy a page diff --git a/docs/ARTIFACT_CONTRACT.md b/docs/ARTIFACT_CONTRACT.md index c208667e..7898ac41 100644 --- a/docs/ARTIFACT_CONTRACT.md +++ b/docs/ARTIFACT_CONTRACT.md @@ -34,7 +34,7 @@ The full artifact provides richer inspection-oriented output for debugging and d **Examples:** - `jtk issues get --full` → agent fields + created, updated, reporter, components, labels -- `cfl page view --full` → agent fields + version, created, modified, author +- `cfl page view --full` → agent fields + parent ID, creation time, author ID ## Page Body Format (cfl) @@ -57,6 +57,9 @@ The full artifact provides richer inspection-oriented output for debugging and d --body-format xhtml → selected artifact with exact storage XHTML body ``` +`cfl page view --full` cannot be combined with `--content-only` or `--web`. +Commands without a defined full artifact reject `--full` instead of ignoring it. + ## Output Format JTK and CFL both use text-first output. The `-o json` resource surface has been removed from both tools (JTK earlier, then CFL via #392); CFL retains `-o table` and `-o plain` only. JSON is reserved for control-plane envelopes (`cfl set-credential --json`, `jtk set-credential --json`) and round-trip payloads (`jtk automation export`). diff --git a/skills/Confluence/CliReference.md b/skills/Confluence/CliReference.md index ace7c784..8876ebcb 100644 --- a/skills/Confluence/CliReference.md +++ b/skills/Confluence/CliReference.md @@ -25,7 +25,7 @@ cfl config test | Flag | Description | |------|-------------| | `-o, --output FORMAT` | Output format (see SKILL.md "Output Representation and Format"): `table` (default), `json`, `plain` | -| `--full` | Inspection-oriented representation (see SKILL.md). Not a content-truncation flag — for `page view` content truncation, use `--no-truncate`. | +| `--full` | Inspection additions on supported page/space views and list/search commands. Unsupported commands reject it. | | `--no-color` | Disable colored output | | `-c, --config PATH` | Override config file location (default: `~/.config/cfl/config.yml`) | @@ -41,6 +41,7 @@ cfl [resource] [action] [ID] [flags] |---------|-------------| | `cfl page list --space KEY` | List pages in space | | `cfl page view PAGE_ID` | View page content as markdown (truncated at 5000 chars by default) | +| `cfl page view PAGE_ID --full` | Add parent ID, creation time, and author ID metadata | | `cfl page view PAGE_ID --no-truncate` | View full content without truncation | | `cfl page view PAGE_ID --content-only` | Output content only (no metadata headers); implies `--no-truncate` | | `cfl page view PAGE_ID --body-format xhtml` | View exact Confluence storage XHTML | @@ -84,6 +85,8 @@ cfl [resource] [action] [ID] [flags] | `--show-macros` | Show macro placeholders (e.g. `[TOC]`) instead of stripping them | | `-w, --web` | Open in browser | +`--full` composes with `--body-format` and is incompatible with `--content-only` and `--web`. + ### Page List Flags | Flag | Description | @@ -116,25 +119,26 @@ echo "# Hello World" | cfl page create -s DEV -t "My Page" | Command | Description | |---------|-------------| -| `cfl search "query"` | Full-text search | +| `cfl search "query"` | Global full-text search | | `cfl search "query" --space KEY` | Search within space | | `cfl search "query" --type page` | Search pages only | | `cfl search --label TAG` | Filter by label | | `cfl search --title "TEXT"` | Filter by title | | `cfl search --cql "CQL_QUERY"` | Raw CQL query | -**Note:** When `--cql` is provided, it takes precedence over the positional `[query]` argument. Don't combine them. +**Scope:** Search is global unless `--space` is explicit; configured `default_space` is not used. +Raw `--cql` cannot be combined with the positional query or any builder flag. ### Search Flags | Flag | Description | |------|-------------| -| `--space KEY` / `-s` | Filter by space key | +| `--space KEY` / `-s` | Explicitly filter by space key | | `--type TYPE` / `-t` | Content type: `page`, `blogpost`, `attachment`, `comment` | | `--label TAG` | Filter by label | | `--title "TEXT"` | Filter by title (contains) | -| `--cql "QUERY"` | Raw CQL query (advanced). Takes precedence over positional query. | -| `--limit N` / `-l` | Max results (default 25) | +| `--cql "QUERY"` | Raw CQL query; mutually exclusive with query, space, type, title, and label inputs | +| `--limit N` / `-l` | Max results, greater than zero (default 25) | ### Common CQL Patterns @@ -187,7 +191,7 @@ echo "# Hello World" | cfl page create -s DEV -t "My Page" | Command | Description | |---------|-------------| | `cfl attachment list --page PAGE_ID` | List attachments on page | -| `cfl attachment list --page PAGE_ID --limit 50` | List with custom limit (default 25) | +| `cfl attachment list --page PAGE_ID --limit 50` | List with positive custom limit (default 25) | | `cfl attachment list --page PAGE_ID --unused` | List orphaned attachments (not referenced in page content) | | `cfl attachment upload --page PAGE_ID --file PATH` | Upload attachment | | `cfl attachment upload --page PAGE_ID --file PATH -m "comment"` | Upload with comment | diff --git a/skills/Confluence/SKILL.md b/skills/Confluence/SKILL.md index 7bfe1844..656b17b6 100644 --- a/skills/Confluence/SKILL.md +++ b/skills/Confluence/SKILL.md @@ -33,6 +33,7 @@ If still missing: ask the user. Then suggest they persist it: - **Config file:** edit the `default_space` field in the file shown by `cfl config show` Env var wins over config. Once set, space-scoped commands work without `--space`. +Search is the exception: `cfl search` is global unless `--space` is provided explicitly. ### Page ID, attachment ID, etc. @@ -46,11 +47,12 @@ No defaults exist. Ask the user. Do not guess. If the user provides a Confluence - **Representation** — what content is shown: - `agent` (default) — curated, action-oriented, LLM-optimized - - `full` (`--full`) — inspection-oriented, additional fields (dates, authors, versions) + - `full` (`--full`) — inspection-oriented additions on page/space views and supported list/search commands - **Page body format** — `--body-format markdown|adf|xhtml` on page view/create/edit. Omission means Markdown; ADF and XHTML are exact. -- **Output format** — how it's rendered: `table` (default), `json` (`-o json`), `plain` (`-o plain`) +- **Output format** — how it's rendered: `table` (default) or `plain` (`-o plain`) -They combine freely — e.g., `--full -o json` returns the inspection representation as JSON. +`page view --full` composes with every body format, but not with `--content-only` or `--web`. +Commands without a defined full artifact reject `--full`. ### Extracting Page IDs from URLs diff --git a/skills/Confluence/Workflows/ManageAttachments.md b/skills/Confluence/Workflows/ManageAttachments.md index aa4badc6..e2d4166e 100644 --- a/skills/Confluence/Workflows/ManageAttachments.md +++ b/skills/Confluence/Workflows/ManageAttachments.md @@ -21,7 +21,7 @@ List, upload, download, and delete attachments on Confluence pages. ```bash cfl attachment list --page PAGE_ID -# Increase result count (default 25) +# Increase result count with a positive limit (default 25) cfl attachment list --page PAGE_ID --limit 100 # Show only unused/orphaned attachments diff --git a/skills/Confluence/Workflows/SearchPages.md b/skills/Confluence/Workflows/SearchPages.md index 9b382c1b..3fe0e6aa 100644 --- a/skills/Confluence/Workflows/SearchPages.md +++ b/skills/Confluence/Workflows/SearchPages.md @@ -12,10 +12,11 @@ Search, find, and filter Confluence pages using full-text search, CQL, or space- | "search in SPACE" | `cfl search "query" --space KEY` | Space-scoped search | | "find pages with label" | `cfl search --label TAG` | Label-based search | | "search by title" | `cfl search --title "text"` | Title-based search | -| "CQL", advanced query | `cfl search --cql "CQL"` | Raw CQL query (takes precedence over positional query) | +| "CQL", advanced query | `cfl search --cql "CQL"` | Raw CQL query | | "list pages in SPACE" | `cfl page list --space KEY` | Simple space listing | -**Note:** `--cql` takes precedence over the positional `[query]` argument. Don't combine them — use one or the other. +**Scope:** Searches are global unless `--space` is provided explicitly. `default_space` is not applied. +Raw `--cql` cannot be combined with a positional query or `--space`, `--type`, `--title`, or `--label`. ### Common Filters (CQL Building Blocks) @@ -58,7 +59,7 @@ cfl search --cql "type=page AND space=KEY AND lastModified > now('-7d')" cfl page list --space KEY ``` -Use `--limit N` to control result count (default 25). +Use a positive `--limit N` to control result count (default 25). ### Scripting / Parsing Output @@ -92,11 +93,8 @@ After returning results: 3. If no results, suggest broadening or adjusting filters 4. Offer to view any specific page from the results -## Missing Space Key +## Search Scope -If a space key is needed but not specified in the request, consult `cfl`'s defaulting order (`--space` flag → `CFL_DEFAULT_SPACE` env var → `default_space` in config file). If still missing, ask the user and suggest persisting it: - -- **Env var (per-shell):** `export CFL_DEFAULT_SPACE=KEY` — add to `.bashrc` / `.zshrc` / equivalent to persist -- **Config file:** edit the `default_space` field in the file shown by `cfl config show` - -Env var wins over config. See the **Defaults & Missing Inputs** section in `SKILL.md` for the full rationale. +Omit `--space` only when global search is intended. If the user asks for a +space-scoped search without naming the space, ask for the key; do not substitute +the configured default. diff --git a/skills/Confluence/Workflows/ViewPage.md b/skills/Confluence/Workflows/ViewPage.md index 061f9c25..9626ea94 100644 --- a/skills/Confluence/Workflows/ViewPage.md +++ b/skills/Confluence/Workflows/ViewPage.md @@ -17,6 +17,7 @@ The default Markdown body is truncated at 5000 chars. This also applies to ADF, | User Says | Command | When to Use | |-----------|---------|-------------| | "view page", "show page", "read page" | `cfl page view PAGE_ID` | Default markdown view (subject to truncation) | +| "show page metadata", "inspect page" | `cfl page view PAGE_ID --full` | Add parent ID, creation time, and author ID | | "show full page", "all content", "no truncation" | `cfl page view PAGE_ID --no-truncate` | Full content without truncation | | "just the content", "content only" | `cfl page view PAGE_ID --content-only` | Content without metadata headers (implies `--no-truncate`) | | "XHTML", "storage format" | `cfl page view PAGE_ID --body-format xhtml` | Exact storage XHTML (subject to truncation) | @@ -25,6 +26,8 @@ The default Markdown body is truncated at 5000 chars. This also applies to ADF, | "open in browser", "open page" | `cfl page view PAGE_ID --web` | Opens in default browser | | "page as JSON" | `cfl page view PAGE_ID -o json` | Full JSON output (body always included in full — no truncation) | +`--full` composes with every `--body-format` and is incompatible with `--content-only` and `--web`. + ### Finding Page IDs If the user provides a page title instead of ID, search first: diff --git a/tools/cfl/CHANGELOG.md b/tools/cfl/CHANGELOG.md index 6b92ed8d..8db43db2 100644 --- a/tools/cfl/CHANGELOG.md +++ b/tools/cfl/CHANGELOG.md @@ -19,6 +19,7 @@ ### Changed +- Search is global unless `--space` is explicit, raw CQL conflicts are rejected, attachment/search limits must be positive, and `page view --full` now emits its documented metadata ([#455](https://github.com/open-cli-collective/atlassian-cli/issues/455)) - `page view`, `page create`, and `page edit` now use `--body-format markdown|adf|xhtml`; removed `--raw`, `--storage`, and `--no-markdown`, made editor buffers representation-safe, and made Markdown conversion fail closed ([#455](https://github.com/open-cli-collective/atlassian-cli/issues/455)) - Improved init and config test UX with user details display ([#56](https://github.com/open-cli-collective/atlassian-cli/pull/56)) diff --git a/tools/cfl/README.md b/tools/cfl/README.md index f3d1904e..1722f08d 100644 --- a/tools/cfl/README.md +++ b/tools/cfl/README.md @@ -286,6 +286,7 @@ View a Confluence page. **Content is displayed as markdown by default.** ```bash cfl page view 12345 +cfl page view 12345 --full # Add parent/creation/author metadata cfl page view 12345 --body-format xhtml cfl page view 12345 --body-format adf cfl page view 12345 --version 7 @@ -303,6 +304,8 @@ cfl page view 12345 --show-macros --content-only | cfl page edit 12345 --legacy | `--show-macros` | | `false` | Show Confluence macro placeholders (e.g., `[TOC]`) instead of stripping them | | `--content-only` | | `false` | Output only page content (no Title/ID/Version headers); implies `--no-truncate` | +`--full` composes with every body format and is incompatible with `--content-only` and `--web`. + **Arguments:** - `` - The page ID (**required**) @@ -486,6 +489,7 @@ cfl page delete 12345 --force ### `cfl search [query]` Search for pages, blog posts, attachments, and comments across Confluence. +Searches are global unless `--space` is provided explicitly; `default_space` is not used. Uses Confluence Query Language (CQL) under the hood. Convenient flags handle common filters, or use `--cql` for advanced queries. @@ -515,16 +519,18 @@ cfl search --cql "type=page AND space=DEV AND lastModified > now('-7d')" | Flag | Short | Default | Description | |------|-------|---------|-------------| -| `--cql` | | | Raw CQL query (advanced) | -| `--space` | `-s` | (from config) | Filter by space key | +| `--cql` | | | Raw CQL query (cannot be combined with query or builder flags) | +| `--space` | `-s` | | Filter by space key | | `--type` | `-t` | | Content type: `page`, `blogpost`, `attachment`, `comment` | | `--title` | | | Filter by title (contains) | | `--label` | | | Filter by label | -| `--limit` | `-l` | `25` | Maximum number of results | +| `--limit` | `-l` | `25` | Maximum number of results (must be greater than zero) | **Arguments:** - `[query]` - Full-text search terms (optional if using filters) +Raw `--cql` is mutually exclusive with `[query]`, `--space`, `--type`, `--title`, and `--label`. + **CQL Reference:** Common CQL operators for `--cql`: - `=` exact match: `type=page` @@ -600,7 +606,7 @@ cfl attachment list --page 12345 --unused | Flag | Short | Default | Description | |------|-------|---------|-------------| | `--page` | `-p` | | Page ID (**required**) | -| `--limit` | `-l` | `25` | Maximum number of attachments to return | +| `--limit` | `-l` | `25` | Maximum number of attachments to return (must be greater than zero) | | `--unused` | | `false` | Show only attachments not referenced in page content | --- diff --git a/tools/cfl/api/pages.go b/tools/cfl/api/pages.go index 55ae1287..498c54bd 100644 --- a/tools/cfl/api/pages.go +++ b/tools/cfl/api/pages.go @@ -272,6 +272,15 @@ func normalizeVersionedPage(page *Page, pageID string, version Version, currentP if page.Links.WebUI == "" { page.Links.WebUI = currentPage.Links.WebUI } + if page.ParentID == "" { + page.ParentID = currentPage.ParentID + } + if page.AuthorID == "" { + page.AuthorID = currentPage.AuthorID + } + if page.CreatedAt == nil { + page.CreatedAt = currentPage.CreatedAt + } } version.Page = nil page.Version = &version diff --git a/tools/cfl/api/pages_test.go b/tools/cfl/api/pages_test.go index 7fbace92..06544d68 100644 --- a/tools/cfl/api/pages_test.go +++ b/tools/cfl/api/pages_test.go @@ -163,6 +163,9 @@ func TestClient_GetPageVersion_LocatesAndFetchesSingleBody(t *testing.T) { "id": "12345", "title": "History Page", "spaceId": "987", + "parentId": "456", + "authorId": "author-1", + "createdAt": "2024-02-03T04:05:06Z", "version": {"number": 3}, "_links": {"webui": "/spaces/DEV/pages/12345"} }`)) @@ -212,6 +215,9 @@ func TestClient_GetPageVersion_LocatesAndFetchesSingleBody(t *testing.T) { testutil.Equal(t, "12345", page.ID) testutil.Equal(t, "History Page", page.Title) testutil.Equal(t, "987", page.SpaceID) + testutil.Equal(t, "456", page.ParentID) + testutil.Equal(t, "author-1", page.AuthorID) + testutil.NotNil(t, page.CreatedAt) testutil.Equal(t, 2, page.Version.Number) testutil.Equal(t, "author-2", page.Version.AuthorID) testutil.Equal(t, "

Version 2

", page.Body.Storage.Value) diff --git a/tools/cfl/api/search.go b/tools/cfl/api/search.go index 7dd9b300..53fe9ef6 100644 --- a/tools/cfl/api/search.go +++ b/tools/cfl/api/search.go @@ -69,6 +69,9 @@ func (c *Client) Search(ctx context.Context, opts *SearchOptions) (*SearchRespon // Build CQL query cql := "" if opts != nil { + if opts.CQL != "" && (opts.Text != "" || opts.Space != "" || opts.Type != "" || opts.Title != "" || opts.Label != "") { + return nil, fmt.Errorf("raw CQL cannot be combined with query-builder fields") + } cql = opts.CQL if cql == "" { cql = buildCQL(opts) diff --git a/tools/cfl/api/search_test.go b/tools/cfl/api/search_test.go index 4b3745f9..648735ea 100644 --- a/tools/cfl/api/search_test.go +++ b/tools/cfl/api/search_test.go @@ -109,12 +109,33 @@ func TestClient_Search_RawCQL(t *testing.T) { client := NewClient(server.URL, "user@example.com", "token") _, err := client.Search(context.Background(), &SearchOptions{ - CQL: `type=page AND lastModified > now("-7d")`, - Text: "ignored", // Should be ignored when CQL is set + CQL: `type=page AND lastModified > now("-7d")`, }) testutil.RequireNoError(t, err) } +func TestClient_Search_RawCQLConflicts(t *testing.T) { + tests := []struct { + name string + opts SearchOptions + }{ + {name: "text", opts: SearchOptions{Text: "query"}}, + {name: "space", opts: SearchOptions{Space: "DEV"}}, + {name: "type", opts: SearchOptions{Type: "page"}}, + {name: "title", opts: SearchOptions{Title: "Title"}}, + {name: "label", opts: SearchOptions{Label: "docs"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.opts.CQL = "type=page" + _, err := (&Client{}).Search(context.Background(), &tt.opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "raw CQL cannot be combined") + }) + } +} + func TestClient_Search_NoQuery(t *testing.T) { t.Parallel() client := NewClient("http://unused", "user@example.com", "token") diff --git a/tools/cfl/internal/cmd/OUTPUT_SPEC.md b/tools/cfl/internal/cmd/OUTPUT_SPEC.md index 51e9bdcf..d30168fe 100644 --- a/tools/cfl/internal/cmd/OUTPUT_SPEC.md +++ b/tools/cfl/internal/cmd/OUTPUT_SPEC.md @@ -41,6 +41,8 @@ does not yet match this document exactly, this spec is the contract to implement `invalid output format: "json" (valid formats: table, plain)`. - `--no-color` may change decoration only; it must not change fields or ordering. - `--full` selects the richer artifact for commands that support it. +- Supported CFL commands are `page list`, `page view`, `space list`, `space view`, + `attachment list`, and `search`; other commands reject `--full` before config loading. - Local control-plane `--json` flags remain command-local and must not be blocked by the global output guard. @@ -194,7 +196,7 @@ Version: ``` -`--full` target additions: +`--full` additions: ```text Parent ID: @@ -204,6 +206,7 @@ Author ID: Flag-specific behavior: - `--content-only` emits only the body and implies untruncated output. +- `--full` is incompatible with `--content-only` and `--web` because both discard metadata. - `--body-format` selects Markdown (default), exact ADF JSON, or exact storage XHTML. - Markdown conversion errors fail with no body on stdout and suggest an exact format. - `--show-macros` affects body conversion only. @@ -282,11 +285,9 @@ Default columns: ID | TITLE | MEDIA TYPE | FILE SIZE ``` -`--full` target: +`--full` additions: -- Same list surface, with richer attachment inspection fields added only when - presenter-backed output is introduced. -- `--full` must never be a silent no-op once the presenter migration lands. +- Adds `STATUS` and `COMMENT` columns. Empty states: @@ -295,6 +296,8 @@ No attachments found. No unused attachments found. ``` +`--limit` must be greater than zero. + ## `attachment upload` Success: @@ -341,6 +344,11 @@ ID | TYPE | SPACE | TITLE | MODIFIED | URL ``` Notes: +- Positional and query-builder searches are global unless `--space` is explicit; + `default_space` is never injected into search CQL. +- Raw `--cql` is mutually exclusive with the positional query and `--space`, + `--type`, `--title`, and `--label`. +- `--limit` must be greater than zero. - The space column label is `SPACE`, not `SPACE KEY`. Current search output still renders the space key in that column, extracted from the result URL. - Default search output must not require JSON decoding, unescaping XHTML, or diff --git a/tools/cfl/internal/cmd/attachment/list.go b/tools/cfl/internal/cmd/attachment/list.go index 9ec3caed..1e63898d 100644 --- a/tools/cfl/internal/cmd/attachment/list.go +++ b/tools/cfl/internal/cmd/attachment/list.go @@ -35,13 +35,19 @@ func newListCmd(rootOpts *root.Options) *cobra.Command { # List unused (orphaned) attachments not referenced in page content cfl attachment list --page 12345 --unused`, + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.NoArgs(cmd, args); err != nil { + return err + } + return validateListOptions(opts) + }, RunE: func(cmd *cobra.Command, _ []string) error { return runList(cmd.Context(), opts) }, } cmd.Flags().StringVarP(&opts.pageID, "page", "p", "", "Page ID (required)") - cmd.Flags().IntVarP(&opts.limit, "limit", "l", 25, "Maximum number of attachments to return") + cmd.Flags().IntVarP(&opts.limit, "limit", "l", 25, "Maximum number of attachments to return (must be greater than 0)") cmd.Flags().BoolVar(&opts.unused, "unused", false, "Show only attachments not referenced in page content") _ = cmd.MarkFlagRequired("page") @@ -50,6 +56,10 @@ func newListCmd(rootOpts *root.Options) *cobra.Command { } func runList(ctx context.Context, opts *listOptions) error { + if err := validateListOptions(opts); err != nil { + return err + } + client, err := opts.APIClient() if err != nil { return err @@ -89,6 +99,13 @@ func runList(ctx context.Context, opts *listOptions) error { return cflpresent.Emit(opts.Options, cflpresent.AttachmentPresenter{}.PresentList(attachments, opts.Full, result.HasMore())) } +func validateListOptions(opts *listOptions) error { + if opts.limit <= 0 { + return fmt.Errorf("invalid limit: %d (must be greater than 0)", opts.limit) + } + return nil +} + // filterUnusedAttachments returns attachments that are not referenced in the page content. // Confluence references attachments in storage format as: // - diff --git a/tools/cfl/internal/cmd/attachment/list_test.go b/tools/cfl/internal/cmd/attachment/list_test.go index a5cea735..dd309280 100644 --- a/tools/cfl/internal/cmd/attachment/list_test.go +++ b/tools/cfl/internal/cmd/attachment/list_test.go @@ -3,8 +3,10 @@ package attachment import ( "bytes" "context" + "fmt" "net/http" "net/http/httptest" + "path/filepath" "testing" "github.com/open-cli-collective/atlassian-go/testutil" @@ -156,6 +158,53 @@ func TestRunList_APIError(t *testing.T) { testutil.Contains(t, err.Error(), "listing attachments") } +func TestRunList_Limits(t *testing.T) { + for _, tt := range []struct { + name string + limit int + wantRequest bool + }{ + {name: "zero", limit: 0}, + {name: "negative", limit: -1}, + {name: "default", limit: 25, wantRequest: true}, + {name: "positive", limit: 50, wantRequest: true}, + } { + t.Run(tt.name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + testutil.Equal(t, fmt.Sprint(tt.limit), r.URL.Query().Get("limit")) + _, _ = w.Write([]byte(`{"results":[]}`)) + })) + defer server.Close() + rootOpts := newListTestRootOptions() + rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) + + err := runList(context.Background(), &listOptions{Options: rootOpts, pageID: "12345", limit: tt.limit}) + if tt.wantRequest { + testutil.RequireNoError(t, err) + testutil.Equal(t, 1, requests) + return + } + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "must be greater than 0") + testutil.Equal(t, 0, requests) + }) + } +} + +func TestList_InvalidLimitFailsBeforeConfig(t *testing.T) { + rootCmd, rootOpts := root.NewCmd() + rootOpts.ConfigPath = filepath.Join(t.TempDir(), "missing.yml") + Register(rootCmd, rootOpts) + rootCmd.SetArgs([]string{"attachment", "list", "--page", "12345", "--limit", "0"}) + + err := rootCmd.Execute() + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "must be greater than 0") + testutil.NotContains(t, err.Error(), "config") +} + func TestIsAttachmentReferenced(t *testing.T) { t.Parallel() tests := []struct { diff --git a/tools/cfl/internal/cmd/page/view.go b/tools/cfl/internal/cmd/page/view.go index 9a05ef5b..23083ce4 100644 --- a/tools/cfl/internal/cmd/page/view.go +++ b/tools/cfl/internal/cmd/page/view.go @@ -44,13 +44,19 @@ Confluence storage XHTML. By default, output is truncated to 5000 characters for concise display. Use --no-truncate to show the complete page content without truncation. -The --content-only flag implies --no-truncate since it is intended for piping.`, +The --content-only flag implies --no-truncate since it is intended for piping. + +Use --full to add parent ID, creation time, and author ID metadata. +It cannot be combined with --content-only or --web.`, Example: ` # View a page (markdown, truncated if large) cfl page view 12345 # View full content without truncation cfl page view 12345 --no-truncate + # View inspection metadata + cfl page view 12345 --full + # View exact storage format (XHTML) cfl page view 12345 --body-format xhtml @@ -79,6 +85,12 @@ The --content-only flag implies --no-truncate since it is intended for piping.`, if opts.showMacros && format != bodyFormatMarkdown { return fmt.Errorf("--show-macros is only supported with --body-format markdown") } + if opts.Full && opts.contentOnly { + return fmt.Errorf("--full is incompatible with --content-only") + } + if opts.Full && opts.web { + return fmt.Errorf("--full is incompatible with --web") + } return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -105,6 +117,12 @@ func runView(ctx context.Context, pageID string, opts *viewOptions) error { if opts.showMacros && bodyFormat != bodyFormatMarkdown { return fmt.Errorf("--show-macros is only supported with --body-format markdown") } + if opts.Full && opts.contentOnly { + return fmt.Errorf("--full is incompatible with --content-only") + } + if opts.Full && opts.web { + return fmt.Errorf("--full is incompatible with --web") + } if opts.contentOnly { if opts.web { return fmt.Errorf("--content-only is incompatible with --web") @@ -162,6 +180,7 @@ func runView(ctx context.Context, pageID string, opts *viewOptions) error { NoTruncate: opts.noTruncate, ShowMacros: opts.showMacros, ContentOnly: opts.contentOnly, + Full: opts.Full, }) if err != nil { return cflpresent.EmitError(opts.Options, err) diff --git a/tools/cfl/internal/cmd/page/view_test.go b/tools/cfl/internal/cmd/page/view_test.go index 1d027433..7b776aff 100644 --- a/tools/cfl/internal/cmd/page/view_test.go +++ b/tools/cfl/internal/cmd/page/view_test.go @@ -7,6 +7,7 @@ import ( "errors" "net/http" "net/http/httptest" + "path/filepath" "strings" "testing" @@ -73,6 +74,9 @@ func TestRunView_ExactOutput_Default(t *testing.T) { "id": "12345", "title": "Test Page", "spaceId": "98765", + "parentId": "456", + "authorId": "author-1", + "createdAt": "2024-02-03T04:05:06Z", "version": {"number": 3}, "body": {"storage": {"value": "

Hello World

"}}, "_links": {"webui": "/pages/12345"} @@ -102,6 +106,73 @@ func TestRunView_ExactOutput_Default(t *testing.T) { testutil.Equal(t, "", rootOpts.Stderr.(*bytes.Buffer).String()) } +func TestRunView_FullMetadataAcrossBodyFormats(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + format string + apiFormat string + body string + wantBody string + }{ + {name: "markdown", format: bodyFormatMarkdown, apiFormat: "storage", body: `"storage":{"value":"

Hello

"}`, wantBody: "Hello"}, + {name: "adf", format: bodyFormatADF, apiFormat: "atlas_doc_format", body: `"atlas_doc_format":{"value":"{\"type\":\"doc\",\"version\":1,\"content\":[]}"}`, wantBody: `{"type":"doc","version":1,"content":[]}`}, + {name: "xhtml", format: bodyFormatXHTML, apiFormat: "storage", body: `"storage":{"value":"

Hello

"}`, wantBody: "

Hello

"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + testutil.Equal(t, tt.apiFormat, r.URL.Query().Get("body-format")) + _, _ = w.Write([]byte(`{ + "id":"12345","title":"Full Page","parentId":"456", + "authorId":"author-1","createdAt":"2024-02-03T04:05:06Z", + "version":{"number":3},"body":{` + tt.body + `} + }`)) + })) + defer server.Close() + + rootOpts := newViewTestRootOptions() + rootOpts.Full = true + rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) + + err := runView(context.Background(), "12345", &viewOptions{Options: rootOpts, bodyFormat: tt.format}) + testutil.RequireNoError(t, err) + testutil.Equal(t, "Title: Full Page\nID: 12345\nVersion: 3\nParent ID: 456\nCreated At: 2024-02-03T04:05:06Z\nAuthor ID: author-1\n\n"+tt.wantBody+"\n", rootOpts.Stdout.(*bytes.Buffer).String()) + }) + } +} + +func TestRunView_FullContentOnlyFailsBeforeNetwork(t *testing.T) { + t.Parallel() + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ })) + defer server.Close() + + rootOpts := newViewTestRootOptions() + rootOpts.Full = true + rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) + err := runView(context.Background(), "12345", &viewOptions{Options: rootOpts, contentOnly: true}) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "--full is incompatible with --content-only") + testutil.Equal(t, 0, requests) + testutil.Equal(t, "", rootOpts.Stdout.(*bytes.Buffer).String()) +} + +func TestView_FullContentOnlyFailsBeforeConfig(t *testing.T) { + rootCmd, rootOpts := root.NewCmd() + rootOpts.ConfigPath = filepath.Join(t.TempDir(), "missing.yml") + Register(rootCmd, rootOpts) + rootCmd.SetArgs([]string{"page", "view", "12345", "--full", "--content-only"}) + + err := rootCmd.Execute() + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "--full is incompatible with --content-only") + testutil.NotContains(t, err.Error(), "config") +} + func TestRunView_ExactOutput_ContentOnly(t *testing.T) { t.Parallel() @@ -581,6 +652,19 @@ func TestRunView_ExactOutput_VersionDefault(t *testing.T) { testutil.Equal(t, "", rootOpts.Stderr.(*bytes.Buffer).String()) } +func TestRunView_VersionFullMetadata(t *testing.T) { + t.Parallel() + server := mockVersionedViewServer(t, "

Historical

") + defer server.Close() + + rootOpts := newViewTestRootOptions() + rootOpts.Full = true + rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) + err := runView(context.Background(), "12345", &viewOptions{Options: rootOpts, version: 2}) + testutil.RequireNoError(t, err) + testutil.Contains(t, rootOpts.Stdout.(*bytes.Buffer).String(), "Parent ID: 456\nCreated At: 2024-02-03T04:05:06Z\nAuthor ID: author-1") +} + func TestRunView_VersionRaw(t *testing.T) { t.Parallel() server := mockVersionedViewServer(t, "

Historical Raw

") @@ -864,7 +948,7 @@ func TestTruncateContent(t *testing.T) { testutil.True(t, truncated) }) - t.Run("--full bypasses truncation", func(t *testing.T) { + t.Run("--no-truncate bypasses truncation", func(t *testing.T) { t.Parallel() long := strings.Repeat("x", maxViewChars+100) result, truncated := pageview.TruncateContent(long, pageview.Options{NoTruncate: true}) @@ -1090,6 +1174,9 @@ func mockVersionedViewServer(t *testing.T, storage string) *httptest.Server { _, _ = w.Write([]byte(`{ "id": "12345", "title": "Versioned Page", + "parentId": "456", + "authorId": "author-1", + "createdAt": "2024-02-03T04:05:06Z", "version": {"number": 3} }`)) case r.URL.Path == "/api/v2/pages/12345/versions" && r.URL.Query().Get("body-format") == "" && r.URL.Query().Get("cursor") == "": diff --git a/tools/cfl/internal/cmd/root/root.go b/tools/cfl/internal/cmd/root/root.go index 9700578b..b81f8a91 100644 --- a/tools/cfl/internal/cmd/root/root.go +++ b/tools/cfl/internal/cmd/root/root.go @@ -176,6 +176,9 @@ Get started by running: cfl init`, if err := validateOutputFormat(opts.Output); err != nil { return err } + if opts.Full && !supportsFull(cmd) { + return fmt.Errorf("--full is not supported for %s", cmd.CommandPath()) + } cfg, err := opts.loadConfig() if err != nil { return err @@ -188,7 +191,7 @@ Get started by running: cfl init`, cmd.PersistentFlags().StringVarP(&opts.ConfigPath, "config", "c", config.DefaultConfigPath(), "config file") cmd.PersistentFlags().StringVarP(&opts.Output, "output", "o", "table", "output format: table, plain") cmd.PersistentFlags().BoolVar(&opts.NoColor, "no-color", false, "disable colored output") - cmd.PersistentFlags().BoolVar(&opts.Full, "full", false, "show full inspection-oriented output (default: agent)") + cmd.PersistentFlags().BoolVar(&opts.Full, "full", false, "show full inspection-oriented output on supported view/list/search commands") cmd.PersistentFlags().BoolVar(&opts.NonInteractive, "non-interactive", false, "Never prompt; fail loud naming any required value missing from flags/env/stdin (§3.4)") cmd.PersistentFlags().String(cccredstore.BackendFlagName, "", cccredstore.BackendFlagUsage()) @@ -198,6 +201,15 @@ Get started by running: cfl init`, return cmd, opts } +func supportsFull(cmd *cobra.Command) bool { + switch cmd.CommandPath() { + case "cfl page list", "cfl page view", "cfl space list", "cfl space view", "cfl attachment list", "cfl search": + return true + default: + return false + } +} + // validateOutputFormat enforces the §2 closed set for cfl's resource // surface. JSON is reserved for round-trip payloads + control-plane // envelopes (e.g. set-credential's local --json flag) and is rejected diff --git a/tools/cfl/internal/cmd/root/root_test.go b/tools/cfl/internal/cmd/root/root_test.go index 9c44afbe..4e37694b 100644 --- a/tools/cfl/internal/cmd/root/root_test.go +++ b/tools/cfl/internal/cmd/root/root_test.go @@ -41,6 +41,19 @@ func TestNewCmd(t *testing.T) { testutil.NotNil(t, fullFlag) } +func TestFullRejectedForUnsupportedCommandBeforeConfig(t *testing.T) { + cmd, opts := NewCmd() + pageCmd := &cobra.Command{Use: "page"} + pageCmd.AddCommand(&cobra.Command{Use: "create", RunE: func(*cobra.Command, []string) error { return nil }}) + cmd.AddCommand(pageCmd) + opts.ConfigPath = filepath.Join(t.TempDir(), "missing.yml") + cmd.SetArgs([]string{"page", "create", "--full"}) + + err := cmd.Execute() + testutil.RequireError(t, err) + testutil.Equal(t, "--full is not supported for cfl page create", err.Error()) +} + func TestNewCmd_Flags(t *testing.T) { t.Parallel() cmd, _ := NewCmd() diff --git a/tools/cfl/internal/cmd/search/search.go b/tools/cfl/internal/cmd/search/search.go index 2eabaaa8..01a8db7f 100644 --- a/tools/cfl/internal/cmd/search/search.go +++ b/tools/cfl/internal/cmd/search/search.go @@ -51,7 +51,10 @@ func newSearchCmd(rootOpts *root.Options) *cobra.Command { Long: `Search for pages, blog posts, attachments, and comments in Confluence. Uses Confluence Query Language (CQL) under the hood. You can use the -convenient flags for common filters, or provide raw CQL for advanced queries.`, +convenient flags for common filters, or provide raw CQL for advanced queries. + +Positional and builder-flag searches are global unless --space is provided. +Raw --cql cannot be combined with the positional query or builder flags.`, Example: ` # Full-text search across all content cfl search "deployment guide" @@ -72,67 +75,44 @@ convenient flags for common filters, or provide raw CQL for advanced queries.`, # Power user: raw CQL query cfl search --cql "type=page AND space=DEV AND lastModified > now('-7d')"`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { + Args: func(cmd *cobra.Command, args []string) error { + if err := cobra.MaximumNArgs(1)(cmd, args); err != nil { + return err + } + opts.query = "" if len(args) > 0 { opts.query = args[0] } + return validateSearchOptions(opts) + }, + RunE: func(cmd *cobra.Command, _ []string) error { return runSearch(cmd.Context(), opts) }, } // Query building flags - cmd.Flags().StringVar(&opts.cql, "cql", "", "Raw CQL query (advanced)") + cmd.Flags().StringVar(&opts.cql, "cql", "", "Raw CQL query; cannot be combined with query-builder inputs") cmd.Flags().StringVarP(&opts.space, "space", "s", "", "Filter by space key") cmd.Flags().StringVarP(&opts.contentType, "type", "t", "", "Content type: page, blogpost, attachment, comment") cmd.Flags().StringVar(&opts.title, "title", "", "Filter by title (contains)") cmd.Flags().StringVar(&opts.label, "label", "", "Filter by label") // Pagination - cmd.Flags().IntVarP(&opts.limit, "limit", "l", 25, "Maximum number of results") + cmd.Flags().IntVarP(&opts.limit, "limit", "l", 25, "Maximum number of results (must be greater than 0)") return cmd } func runSearch(ctx context.Context, opts *searchOptions) error { - // Validate type if provided - if opts.contentType != "" && !validTypes[opts.contentType] { - validList := []string{"page", "blogpost", "attachment", "comment"} - return fmt.Errorf("invalid type %q: must be one of %s", opts.contentType, strings.Join(validList, ", ")) - } - - // Validate that we have something to search for - if opts.cql == "" && opts.query == "" && opts.space == "" && opts.contentType == "" && opts.title == "" && opts.label == "" { - return fmt.Errorf("search requires a query, --cql, or at least one filter (--space, --type, --title, --label)") - } - - // Validate limit - if opts.limit < 0 { - return fmt.Errorf("invalid limit: %d (must be >= 0)", opts.limit) - } - - if opts.limit == 0 { - return cflpresent.Emit(opts.Options, cflpresent.SearchPresenter{}.PresentEmpty()) - } - - // Get config for default space - cfg, err := opts.Config() - if err != nil { + if err := validateSearchOptions(opts); err != nil { return err } - // Use default space from config if not specified and no cql override - if opts.space == "" && opts.cql == "" { - opts.space = cfg.DefaultSpace - } - - // Get API client client, err := opts.APIClient() if err != nil { return err } - // Build API options apiOpts := &api.SearchOptions{ CQL: opts.cql, Text: opts.query, @@ -153,3 +133,26 @@ func runSearch(ctx context.Context, opts *searchOptions) error { } return cflpresent.Emit(opts.Options, cflpresent.SearchPresenter{}.PresentList(result.Results, opts.Full, result.TotalSize, result.HasMore())) } + +func validateSearchOptions(opts *searchOptions) error { + // Validate type if provided + if opts.contentType != "" && !validTypes[opts.contentType] { + validList := []string{"page", "blogpost", "attachment", "comment"} + return fmt.Errorf("invalid type %q: must be one of %s", opts.contentType, strings.Join(validList, ", ")) + } + + // Validate that we have something to search for + if opts.cql == "" && opts.query == "" && opts.space == "" && opts.contentType == "" && opts.title == "" && opts.label == "" { + return fmt.Errorf("search requires a query, --cql, or at least one filter (--space, --type, --title, --label)") + } + + if opts.cql != "" && (opts.query != "" || opts.space != "" || opts.contentType != "" || opts.title != "" || opts.label != "") { + return fmt.Errorf("--cql cannot be combined with a positional query or --space, --type, --title, or --label") + } + + if opts.limit <= 0 { + return fmt.Errorf("invalid limit: %d (must be greater than 0)", opts.limit) + } + + return nil +} diff --git a/tools/cfl/internal/cmd/search/search_test.go b/tools/cfl/internal/cmd/search/search_test.go index 6e548421..17516ab2 100644 --- a/tools/cfl/internal/cmd/search/search_test.go +++ b/tools/cfl/internal/cmd/search/search_test.go @@ -3,8 +3,10 @@ package search import ( "bytes" "context" + "fmt" "net/http" "net/http/httptest" + "path/filepath" "strings" "testing" @@ -255,34 +257,21 @@ func TestRunSearch_NoQuery(t *testing.T) { testutil.Contains(t, err.Error(), "--type") } -func TestRunSearch_NegativeLimit(t *testing.T) { - t.Parallel() - rootOpts := newTestRootOptions() - - opts := &searchOptions{ - Options: rootOpts, - query: "test", - limit: -1, - } - - err := runSearch(context.Background(), opts) - testutil.RequireError(t, err) - testutil.Contains(t, err.Error(), "invalid limit") -} - -func TestRunSearch_ZeroLimit(t *testing.T) { - t.Parallel() - rootOpts := newTestRootOptions() +func TestRunSearch_InvalidLimitsMakeNoRequest(t *testing.T) { + for _, limit := range []int{0, -1} { + t.Run(fmt.Sprintf("limit_%d", limit), func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ })) + defer server.Close() + rootOpts := newTestRootOptions() + rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) - opts := &searchOptions{ - Options: rootOpts, - query: "test", - limit: 0, + err := runSearch(context.Background(), &searchOptions{Options: rootOpts, query: "test", limit: limit}) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "must be greater than 0") + testutil.Equal(t, 0, requests) + }) } - - // Zero limit should return empty without making API call - err := runSearch(context.Background(), opts) - testutil.RequireNoError(t, err) } func TestRunSearch_WithSpaceFilter(t *testing.T) { @@ -337,12 +326,12 @@ func TestRunSearch_WithTypeFilter(t *testing.T) { testutil.RequireNoError(t, err) } -func TestRunSearch_TypeOnly_UsesDefaultSpaceAfterValidation(t *testing.T) { +func TestRunSearch_DoesNotUseDefaultSpace(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cql := r.URL.Query().Get("cql") testutil.Contains(t, cql, `type = "page"`) - testutil.Contains(t, cql, `space = "DEV"`) + testutil.NotContains(t, cql, `space = "DEV"`) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"results": [], "totalSize": 0}`)) @@ -440,6 +429,65 @@ func TestRunSearch_WithRawCQL(t *testing.T) { testutil.RequireNoError(t, err) } +func TestRunSearch_RawCQLConflictsMakeNoRequest(t *testing.T) { + tests := []struct { + name string + opts searchOptions + }{ + {name: "positional query", opts: searchOptions{query: "query"}}, + {name: "space", opts: searchOptions{space: "DEV"}}, + {name: "type", opts: searchOptions{contentType: "page"}}, + {name: "title", opts: searchOptions{title: "Title"}}, + {name: "label", opts: searchOptions{label: "docs"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests++ })) + defer server.Close() + rootOpts := newTestRootOptions() + rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) + tt.opts.Options = rootOpts + tt.opts.cql = "type=page" + tt.opts.limit = 25 + + err := runSearch(context.Background(), &tt.opts) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "--cql cannot be combined") + testutil.Equal(t, 0, requests) + testutil.Equal(t, "", rootOpts.Stdout.(*bytes.Buffer).String()) + }) + } +} + +func TestSearch_CQLAndPositionalFailBeforeConfig(t *testing.T) { + rootCmd, rootOpts := root.NewCmd() + rootOpts.ConfigPath = filepath.Join(t.TempDir(), "missing.yml") + rootOpts.Stdout = &bytes.Buffer{} + rootOpts.Stderr = &bytes.Buffer{} + Register(rootCmd, rootOpts) + rootCmd.SetArgs([]string{"search", "query", "--cql", "type=page"}) + + err := rootCmd.Execute() + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "--cql cannot be combined") + testutil.NotContains(t, err.Error(), "config") + testutil.Equal(t, "", rootOpts.Stdout.(*bytes.Buffer).String()) +} + +func TestSearch_InvalidLimitFailsBeforeConfig(t *testing.T) { + rootCmd, rootOpts := root.NewCmd() + rootOpts.ConfigPath = filepath.Join(t.TempDir(), "missing.yml") + Register(rootCmd, rootOpts) + rootCmd.SetArgs([]string{"search", "query", "--limit", "0"}) + + err := rootCmd.Execute() + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "must be greater than 0") + testutil.NotContains(t, err.Error(), "config") +} + func TestRunSearch_CombinedFilters(t *testing.T) { t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -574,28 +622,20 @@ func TestRunSearch_SpaceOnlyFilter(t *testing.T) { } func TestRunSearch_LimitParameter(t *testing.T) { - t.Parallel() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - limit := r.URL.Query().Get("limit") - testutil.Equal(t, "50", limit) - - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"results": [], "totalSize": 0}`)) - })) - defer server.Close() - - rootOpts := newTestRootOptions() - client := api.NewClient(server.URL, "test@example.com", "token") - rootOpts.SetAPIClient(client) + for _, limit := range []int{25, 50} { + t.Run(fmt.Sprint(limit), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + testutil.Equal(t, fmt.Sprint(limit), r.URL.Query().Get("limit")) + _, _ = w.Write([]byte(`{"results": [], "totalSize": 0}`)) + })) + defer server.Close() - opts := &searchOptions{ - Options: rootOpts, - query: "test", - limit: 50, + rootOpts := newTestRootOptions() + rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) + err := runSearch(context.Background(), &searchOptions{Options: rootOpts, query: "test", limit: limit}) + testutil.RequireNoError(t, err) + }) } - - err := runSearch(context.Background(), opts) - testutil.RequireNoError(t, err) } func TestExtractSpaceKey(t *testing.T) { diff --git a/tools/cfl/internal/pageview/projection.go b/tools/cfl/internal/pageview/projection.go index 7290ca10..c33a2fbe 100644 --- a/tools/cfl/internal/pageview/projection.go +++ b/tools/cfl/internal/pageview/projection.go @@ -3,6 +3,8 @@ package pageview import ( "fmt" + "github.com/open-cli-collective/atlassian-go/atime" + "github.com/open-cli-collective/confluence-cli/api" "github.com/open-cli-collective/confluence-cli/pkg/md" ) @@ -16,6 +18,7 @@ type Options struct { NoTruncate bool ShowMacros bool ContentOnly bool + Full bool } // BodyKind identifies the body representation selected for presentation. @@ -36,6 +39,10 @@ type Projection struct { SpaceID string Version int HasVersion bool + ParentID string + CreatedAt *atime.AtlassianTime + AuthorID string + Full bool ContentOnly bool Body string BodyKind BodyKind @@ -76,6 +83,10 @@ func Project(page *api.Page, spaceKey string, opts Options) (Projection, error) ID: page.ID, SpaceKey: spaceKey, SpaceID: page.SpaceID, + ParentID: page.ParentID, + CreatedAt: page.CreatedAt, + AuthorID: page.AuthorID, + Full: opts.Full, ContentOnly: opts.ContentOnly, } if page.Version != nil { diff --git a/tools/cfl/internal/present/detail.go b/tools/cfl/internal/present/detail.go index ecef8ff0..2b1f84e1 100644 --- a/tools/cfl/internal/present/detail.go +++ b/tools/cfl/internal/present/detail.go @@ -96,6 +96,13 @@ func (PagePresenter) PresentView(proj pageview.Projection) *sharedpresent.Output Value: fmt.Sprintf("%d", proj.Version), }) } + if proj.Full { + fields = append(fields, + sharedpresent.Field{Label: "Parent ID", Value: orDash(proj.ParentID)}, + sharedpresent.Field{Label: "Created At", Value: formatHistoryTime(proj.CreatedAt)}, + sharedpresent.Field{Label: "Author ID", Value: orDash(proj.AuthorID)}, + ) + } sections = append(sections, &sharedpresent.DetailSection{Fields: fields}) } From b8bd6c590ebe81955aa639d6d1c762701d86a931 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Fri, 17 Jul 2026 08:08:24 -0400 Subject: [PATCH 04/12] fix(cfl): address review feedback on search/limits/--full batch (#455) --- skills/Confluence/CliReference.md | 6 ++---- tools/cfl/README.md | 9 ++++++--- tools/cfl/api/search_test.go | 2 ++ tools/cfl/internal/cmd/OUTPUT_SPEC.md | 1 + tools/cfl/internal/cmd/page/view_test.go | 12 ++++++++++++ 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/skills/Confluence/CliReference.md b/skills/Confluence/CliReference.md index 8876ebcb..74010397 100644 --- a/skills/Confluence/CliReference.md +++ b/skills/Confluence/CliReference.md @@ -24,7 +24,7 @@ cfl config test | Flag | Description | |------|-------------| -| `-o, --output FORMAT` | Output format (see SKILL.md "Output Representation and Format"): `table` (default), `json`, `plain` | +| `-o, --output FORMAT` | Output format (see SKILL.md "Output Representation and Format"): `table` (default), `plain` | | `--full` | Inspection additions on supported page/space views and list/search commands. Unsupported commands reject it. | | `--no-color` | Disable colored output | | `-c, --config PATH` | Override config file location (default: `~/.config/cfl/config.yml`) | @@ -48,7 +48,6 @@ cfl [resource] [action] [ID] [flags] | `cfl page view PAGE_ID --body-format adf` | View exact ADF JSON | | `cfl page view PAGE_ID --show-macros` | Show macro placeholders (e.g. `[TOC]`) instead of stripping them | | `cfl page view PAGE_ID --web` | Open page in browser | -| `cfl page view PAGE_ID -o json` | Full JSON output (body always included in full) | | `cfl page create --space KEY --title "TEXT"` | Create page (opens editor) | | `cfl page create --space KEY --title "TEXT" --file content.md` | Create from file | | `cfl page create --space KEY --title "TEXT" --parent PAGE_ID` | Create as child page | @@ -204,7 +203,6 @@ Raw `--cql` cannot be combined with the positional query or any builder flag. ## Output - Default representation: `agent`; default format: `table`. See SKILL.md "Output Representation and Format" for the full model. -- Use `-o json` for machine-readable output (useful for scripting — e.g. extracting IDs from search results or storage-format body from a page) -- Use `-o plain` for plain text +- Use `-o plain` (TSV) for machine-readable output; JSON output was removed — parse the stable plain/table columns, or use `--body-format adf|xhtml` for exact page body representations - Use `--no-color` to disable colored output - Data goes to stdout (pipeable) diff --git a/tools/cfl/README.md b/tools/cfl/README.md index 1722f08d..2e1b105c 100644 --- a/tools/cfl/README.md +++ b/tools/cfl/README.md @@ -123,13 +123,15 @@ sudo rpm -i cfl-VERSION.x86_64.rpm ### From Source -**Go install** +**Build from a clone** ```bash -go install github.com/open-cli-collective/confluence-cli/cmd/cfl@latest +git clone https://github.com/open-cli-collective/atlassian-cli.git +cd atlassian-cli +make build-cfl ``` -Requires Go 1.24 or later. +Requires Go 1.24 or later. (`go install` of the old `confluence-cli` module path installs a stale pre-monorepo binary; use Homebrew or a clone instead.) ## Quick Start @@ -186,6 +188,7 @@ These flags are available on all commands: | `--config` | `-c` | `~/.config/cfl/config.yml` | Path to config file | | `--output` | `-o` | `table` | Output format: `table`, `plain` | | `--no-color` | | `false` | Disable colored output | +| `--full` | | `false` | Inspection-oriented output; supported on view, list, and search commands (rejected elsewhere) | | `--help` | `-h` | | Show help for command | | `--version` | `-v` | | Show version (root command only) | diff --git a/tools/cfl/api/search_test.go b/tools/cfl/api/search_test.go index 648735ea..1c8b41ff 100644 --- a/tools/cfl/api/search_test.go +++ b/tools/cfl/api/search_test.go @@ -115,6 +115,7 @@ func TestClient_Search_RawCQL(t *testing.T) { } func TestClient_Search_RawCQLConflicts(t *testing.T) { + t.Parallel() tests := []struct { name string opts SearchOptions @@ -128,6 +129,7 @@ func TestClient_Search_RawCQLConflicts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() tt.opts.CQL = "type=page" _, err := (&Client{}).Search(context.Background(), &tt.opts) testutil.RequireError(t, err) diff --git a/tools/cfl/internal/cmd/OUTPUT_SPEC.md b/tools/cfl/internal/cmd/OUTPUT_SPEC.md index d30168fe..32fd3e5c 100644 --- a/tools/cfl/internal/cmd/OUTPUT_SPEC.md +++ b/tools/cfl/internal/cmd/OUTPUT_SPEC.md @@ -207,6 +207,7 @@ Author ID: Flag-specific behavior: - `--content-only` emits only the body and implies untruncated output. - `--full` is incompatible with `--content-only` and `--web` because both discard metadata. +- `--content-only` is incompatible with `--web` (web opens a browser and emits no body text). - `--body-format` selects Markdown (default), exact ADF JSON, or exact storage XHTML. - Markdown conversion errors fail with no body on stdout and suggest an exact format. - `--show-macros` affects body conversion only. diff --git a/tools/cfl/internal/cmd/page/view_test.go b/tools/cfl/internal/cmd/page/view_test.go index 7b776aff..41ca054e 100644 --- a/tools/cfl/internal/cmd/page/view_test.go +++ b/tools/cfl/internal/cmd/page/view_test.go @@ -173,6 +173,18 @@ func TestView_FullContentOnlyFailsBeforeConfig(t *testing.T) { testutil.NotContains(t, err.Error(), "config") } +func TestView_FullWebFailsBeforeConfig(t *testing.T) { + rootCmd, rootOpts := root.NewCmd() + rootOpts.ConfigPath = filepath.Join(t.TempDir(), "missing.yml") + Register(rootCmd, rootOpts) + rootCmd.SetArgs([]string{"page", "view", "12345", "--full", "--web"}) + + err := rootCmd.Execute() + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "--full is incompatible with --web") + testutil.NotContains(t, err.Error(), "config") +} + func TestRunView_ExactOutput_ContentOnly(t *testing.T) { t.Parallel() From 6d343f14992f4e4de778245d3a262be94639ba2b Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Fri, 17 Jul 2026 09:44:28 -0400 Subject: [PATCH 05/12] fix(cfl): address review feedback on body-format batch (#455) --- skills/Confluence/CliReference.md | 8 +++---- skills/Confluence/SKILL.md | 4 ++-- skills/Confluence/Workflows/ManagePage.md | 2 +- skills/Confluence/Workflows/ViewPage.md | 20 +--------------- tools/cfl/internal/cmd/page/body_format.go | 7 +++--- tools/cfl/internal/cmd/page/edit.go | 6 +---- tools/cfl/internal/cmd/page/edit_test.go | 17 ++++--------- tools/cfl/internal/cmd/page/fetch.go | 19 +++------------ tools/cfl/internal/cmd/page/fetch_test.go | 21 ++++++++-------- tools/cfl/internal/pageview/projection.go | 28 +++++++++++++++------- 10 files changed, 49 insertions(+), 83 deletions(-) diff --git a/skills/Confluence/CliReference.md b/skills/Confluence/CliReference.md index ace7c784..6cdf7fcd 100644 --- a/skills/Confluence/CliReference.md +++ b/skills/Confluence/CliReference.md @@ -24,7 +24,7 @@ cfl config test | Flag | Description | |------|-------------| -| `-o, --output FORMAT` | Output format (see SKILL.md "Output Representation and Format"): `table` (default), `json`, `plain` | +| `-o, --output FORMAT` | Output format (see SKILL.md "Output Representation and Format"): `table` (default), `plain` | | `--full` | Inspection-oriented representation (see SKILL.md). Not a content-truncation flag — for `page view` content truncation, use `--no-truncate`. | | `--no-color` | Disable colored output | | `-c, --config PATH` | Override config file location (default: `~/.config/cfl/config.yml`) | @@ -47,7 +47,6 @@ cfl [resource] [action] [ID] [flags] | `cfl page view PAGE_ID --body-format adf` | View exact ADF JSON | | `cfl page view PAGE_ID --show-macros` | Show macro placeholders (e.g. `[TOC]`) instead of stripping them | | `cfl page view PAGE_ID --web` | Open page in browser | -| `cfl page view PAGE_ID -o json` | Full JSON output (body always included in full) | | `cfl page create --space KEY --title "TEXT"` | Create page (opens editor) | | `cfl page create --space KEY --title "TEXT" --file content.md` | Create from file | | `cfl page create --space KEY --title "TEXT" --parent PAGE_ID` | Create as child page | @@ -105,7 +104,7 @@ Storage-format round-trip (lossless — preserves macros and all formatting): - Modify the XHTML - Send it back with `cfl page edit PAGE_ID --body-format xhtml` (stdin or `--file`) -See ViewPage.md for the JSON output structure and ManagePage.md for a full walkthrough. +See ManagePage.md for a full walkthrough. Create from stdin: ```bash @@ -200,7 +199,6 @@ echo "# Hello World" | cfl page create -s DEV -t "My Page" ## Output - Default representation: `agent`; default format: `table`. See SKILL.md "Output Representation and Format" for the full model. -- Use `-o json` for machine-readable output (useful for scripting — e.g. extracting IDs from search results or storage-format body from a page) -- Use `-o plain` for plain text +- Use `-o plain` (TSV) for machine-readable output; JSON output was removed — parse the stable plain/table columns, or use `--body-format adf|xhtml` for exact page body representations - Use `--no-color` to disable colored output - Data goes to stdout (pipeable) diff --git a/skills/Confluence/SKILL.md b/skills/Confluence/SKILL.md index 7bfe1844..2932ade8 100644 --- a/skills/Confluence/SKILL.md +++ b/skills/Confluence/SKILL.md @@ -48,9 +48,9 @@ No defaults exist. Ask the user. Do not guess. If the user provides a Confluence - `agent` (default) — curated, action-oriented, LLM-optimized - `full` (`--full`) — inspection-oriented, additional fields (dates, authors, versions) - **Page body format** — `--body-format markdown|adf|xhtml` on page view/create/edit. Omission means Markdown; ADF and XHTML are exact. -- **Output format** — how it's rendered: `table` (default), `json` (`-o json`), `plain` (`-o plain`) +- **Output format** — how it's rendered: `table` (default), `plain` (`-o plain`). For scripted extraction, parse `ID: ` lines from command output. -They combine freely — e.g., `--full -o json` returns the inspection representation as JSON. +They combine freely — e.g., `--full -o plain` returns the inspection representation as TSV. ### Extracting Page IDs from URLs diff --git a/skills/Confluence/Workflows/ManagePage.md b/skills/Confluence/Workflows/ManagePage.md index e2b57f37..dea3bd91 100644 --- a/skills/Confluence/Workflows/ManagePage.md +++ b/skills/Confluence/Workflows/ManagePage.md @@ -117,7 +117,7 @@ cfl page copy PAGE_ID --title "Light Copy" --no-attachments --no-labels **Placement:** `cfl page copy` always places the new page at the root of the destination space — it does not inherit the source page's parent, and it does not accept `--parent`. To place the copy under a specific page, follow the copy with a reparent edit: ```bash -NEW_ID=$(cfl page copy SOURCE_ID --title "Copy of Page" -o json | ...) # capture the new page ID from JSON +NEW_ID=$(cfl page copy SOURCE_ID --title "Copy of Page" | awk '/^ID:/ {print $2}') cfl page edit $NEW_ID --parent DESIRED_PARENT_ID ``` diff --git a/skills/Confluence/Workflows/ViewPage.md b/skills/Confluence/Workflows/ViewPage.md index 061f9c25..a62e22e5 100644 --- a/skills/Confluence/Workflows/ViewPage.md +++ b/skills/Confluence/Workflows/ViewPage.md @@ -23,7 +23,6 @@ The default Markdown body is truncated at 5000 chars. This also applies to ADF, | "ADF", "Atlassian document format" | `cfl page view PAGE_ID --body-format adf` | Exact ADF JSON (subject to truncation) | | "show macros" | `cfl page view PAGE_ID --show-macros` | Preserve macro placeholders like `[TOC]` (subject to truncation) | | "open in browser", "open page" | `cfl page view PAGE_ID --web` | Opens in default browser | -| "page as JSON" | `cfl page view PAGE_ID -o json` | Full JSON output (body always included in full — no truncation) | ### Finding Page IDs @@ -32,7 +31,7 @@ If the user provides a page title instead of ID, search first: cfl search --title "Page Title" --type page --space KEY ``` -Then use the page ID from the results. For scripted extraction, add `-o json` — see SearchPages.md for the output structure. +Then use the page ID from the results. If the user provides a Confluence URL instead of a page ID, see "Extracting Page IDs from URLs" in SKILL.md. @@ -65,23 +64,6 @@ cfl page view PAGE_ID --web By default, Confluence macros (TOC, include, status, etc.) are stripped from the markdown output. If the page structure depends on macros, use `--show-macros` to preserve their placeholders (e.g. `[TOC]`) so the structure remains visible. -## JSON Output Structure - -With `-o json`, the output has this structure: - -```json -{ - "id": "...", - "title": "...", - "spaceId": "...", - "spaceKey": "...", - "parentId": "...", - "content": "..." -} -``` - -The `content` field holds the full storage-format XHTML with no truncation, regardless of `--no-truncate`. Version and timestamp fields are not included in this output — use the default table view if you need those. - ## Output Format Present page content clearly: diff --git a/tools/cfl/internal/cmd/page/body_format.go b/tools/cfl/internal/cmd/page/body_format.go index a9939993..7dafdf1a 100644 --- a/tools/cfl/internal/cmd/page/body_format.go +++ b/tools/cfl/internal/cmd/page/body_format.go @@ -5,13 +5,14 @@ import ( "fmt" "github.com/open-cli-collective/confluence-cli/api" + "github.com/open-cli-collective/confluence-cli/internal/pageview" "github.com/open-cli-collective/confluence-cli/pkg/md" ) const ( - bodyFormatMarkdown = "markdown" - bodyFormatADF = "adf" - bodyFormatXHTML = "xhtml" + bodyFormatMarkdown = pageview.BodyFormatMarkdown + bodyFormatADF = pageview.BodyFormatADF + bodyFormatXHTML = pageview.BodyFormatXHTML ) func resolveBodyFormat(value string, explicit bool) (string, error) { diff --git a/tools/cfl/internal/cmd/page/edit.go b/tools/cfl/internal/cmd/page/edit.go index f86504ac..0165a6f2 100644 --- a/tools/cfl/internal/cmd/page/edit.go +++ b/tools/cfl/internal/cmd/page/edit.go @@ -129,11 +129,7 @@ func runEdit(ctx context.Context, opts *editOptions) error { !hasContentSource(opts.Options, opts.file, opts.editor) { return errMissingContentSource() } - hasStdinData := opts.Stdin != nil && opts.Stdin != os.Stdin - if !hasStdinData { - stat, _ := os.Stdin.Stat() - hasStdinData = (stat.Mode() & os.ModeCharDevice) == 0 - } + hasStdinData := (opts.Stdin != nil && opts.Stdin != os.Stdin) || hasPipedOSStdin(opts.Options) hasNewContent := opts.file != "" || opts.editor || hasStdinData var newBody *api.Body if hasNewContent && !opts.editor { diff --git a/tools/cfl/internal/cmd/page/edit_test.go b/tools/cfl/internal/cmd/page/edit_test.go index 8e50b4c7..5a2c1ff3 100644 --- a/tools/cfl/internal/cmd/page/edit_test.go +++ b/tools/cfl/internal/cmd/page/edit_test.go @@ -115,22 +115,13 @@ func TestRunEdit_TitleOnly(t *testing.T) { title: "New Title", } - // Note: Without file input and with a title, the current implementation - // will still try to open an editor. For this test to work properly, - // we need to provide a file to avoid the editor path. - tmpDir := t.TempDir() - mdFile := filepath.Join(tmpDir, "content.md") - err := os.WriteFile(mdFile, []byte("

Keep this

"), 0600) - testutil.RequireNoError(t, err) - - opts.file = mdFile - opts.bodyFormat = bodyFormatXHTML - - err = runEdit(context.Background(), opts) + err := runEdit(context.Background(), opts) testutil.RequireNoError(t, err) - // Verify title was changed testutil.Equal(t, "New Title", receivedBody["title"]) + body := receivedBody["body"].(map[string]any) + storage := body["storage"].(map[string]any) + testutil.Equal(t, "

Keep this

", storage["value"]) } func TestRunEdit_PageNotFound(t *testing.T) { diff --git a/tools/cfl/internal/cmd/page/fetch.go b/tools/cfl/internal/cmd/page/fetch.go index c16d4f00..e42b7f3e 100644 --- a/tools/cfl/internal/cmd/page/fetch.go +++ b/tools/cfl/internal/cmd/page/fetch.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/open-cli-collective/confluence-cli/api" + "github.com/open-cli-collective/confluence-cli/internal/pageview" ) func getPageWithBodyFormat(ctx context.Context, client *api.Client, pageID, bodyFormat string) (*api.Page, error) { @@ -68,7 +69,7 @@ func getPageWithBodyFallback(ctx context.Context, client *api.Client, pageID str return nil, err } - if hasStorageContent(page) { + if pageview.HasStorageContent(page) { return page, nil } @@ -96,7 +97,7 @@ func getPageVersionWithBodyFallback(ctx context.Context, client *api.Client, pag return nil, err } - if hasStorageContent(page) { + if pageview.HasStorageContent(page) { return page, nil } @@ -107,17 +108,3 @@ func getPageVersionWithBodyFallback(ctx context.Context, client *api.Client, pag return page, nil } - -// hasStorageContent returns true if the page has non-empty storage format content. -func hasStorageContent(page *api.Page) bool { - return page.Body != nil && - page.Body.Storage != nil && - page.Body.Storage.Value != "" -} - -// hasADFContent returns true if the page has non-empty ADF content. -func hasADFContent(page *api.Page) bool { - return page.Body != nil && - page.Body.AtlasDocFormat != nil && - page.Body.AtlasDocFormat.Value != "" -} diff --git a/tools/cfl/internal/cmd/page/fetch_test.go b/tools/cfl/internal/cmd/page/fetch_test.go index 7ccb20b5..228b7afd 100644 --- a/tools/cfl/internal/cmd/page/fetch_test.go +++ b/tools/cfl/internal/cmd/page/fetch_test.go @@ -10,6 +10,7 @@ import ( "github.com/open-cli-collective/atlassian-go/testutil" "github.com/open-cli-collective/confluence-cli/api" + "github.com/open-cli-collective/confluence-cli/internal/pageview" ) func TestGetPageWithBodyFallback_StorageHasContent(t *testing.T) { @@ -33,7 +34,7 @@ func TestGetPageWithBodyFallback_StorageHasContent(t *testing.T) { page, err := getPageWithBodyFallback(context.Background(), client, "12345") testutil.RequireNoError(t, err) testutil.Equal(t, 1, callCount) - testutil.True(t, hasStorageContent(page)) + testutil.True(t, pageview.HasStorageContent(page)) } func TestGetPageWithBodyFallback_StorageEmpty_FallsBackToADF(t *testing.T) { @@ -68,7 +69,7 @@ func TestGetPageWithBodyFallback_StorageEmpty_FallsBackToADF(t *testing.T) { page, err := getPageWithBodyFallback(context.Background(), client, "12345") testutil.RequireNoError(t, err) testutil.Equal(t, 2, callCount) - testutil.True(t, hasADFContent(page)) + testutil.True(t, pageview.HasADFContent(page)) } func TestGetPageWithBodyFallback_NullBody_FallsBackToADF(t *testing.T) { @@ -103,7 +104,7 @@ func TestGetPageWithBodyFallback_NullBody_FallsBackToADF(t *testing.T) { page, err := getPageWithBodyFallback(context.Background(), client, "12345") testutil.RequireNoError(t, err) testutil.Equal(t, 2, callCount) - testutil.True(t, hasADFContent(page)) + testutil.True(t, pageview.HasADFContent(page)) } func TestGetPageWithBodyFallback_BothEmpty(t *testing.T) { @@ -123,8 +124,8 @@ func TestGetPageWithBodyFallback_BothEmpty(t *testing.T) { client := api.NewClient(server.URL, "test@example.com", "token") page, err := getPageWithBodyFallback(context.Background(), client, "12345") testutil.RequireNoError(t, err) - testutil.False(t, hasStorageContent(page)) - testutil.False(t, hasADFContent(page)) + testutil.False(t, pageview.HasStorageContent(page)) + testutil.False(t, pageview.HasADFContent(page)) } func TestGetPageWithBodyFallback_GetPageError(t *testing.T) { @@ -179,8 +180,8 @@ func TestGetPageWithBodyFallback_ADFFallbackFails_GracefulDegradation(t *testing client := api.NewClient(server.URL, "test@example.com", "token") page, err := getPageWithBodyFallback(context.Background(), client, "12345") testutil.RequireNoError(t, err) - testutil.False(t, hasStorageContent(page)) - testutil.False(t, hasADFContent(page)) + testutil.False(t, pageview.HasStorageContent(page)) + testutil.False(t, pageview.HasADFContent(page)) } func TestGetPageVersionWithBodyFallback_StorageEmpty_FallsBackToADF(t *testing.T) { @@ -229,7 +230,7 @@ func TestGetPageVersionWithBodyFallback_StorageEmpty_FallsBackToADF(t *testing.T page, err := getPageVersionWithBodyFallback(context.Background(), client, "12345", 2) testutil.RequireNoError(t, err) testutil.Equal(t, 4, callCount) - testutil.True(t, hasADFContent(page)) + testutil.True(t, pageview.HasADFContent(page)) } func TestGetPageVersionWithBodyFormat_ADF(t *testing.T) { @@ -270,7 +271,7 @@ func TestHasStorageContent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - testutil.Equal(t, tt.expected, hasStorageContent(tt.page)) + testutil.Equal(t, tt.expected, pageview.HasStorageContent(tt.page)) }) } } @@ -291,7 +292,7 @@ func TestHasADFContent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - testutil.Equal(t, tt.expected, hasADFContent(tt.page)) + testutil.Equal(t, tt.expected, pageview.HasADFContent(tt.page)) }) } } diff --git a/tools/cfl/internal/pageview/projection.go b/tools/cfl/internal/pageview/projection.go index 7290ca10..78e14589 100644 --- a/tools/cfl/internal/pageview/projection.go +++ b/tools/cfl/internal/pageview/projection.go @@ -7,8 +7,16 @@ import ( "github.com/open-cli-collective/confluence-cli/pkg/md" ) -// MaxChars is the default body truncation threshold for page view output. -const MaxChars = 5000 +const ( + // MaxChars is the default body truncation threshold for page view output. + MaxChars = 5000 + // BodyFormatMarkdown is the default page body format. + BodyFormatMarkdown = "markdown" + // BodyFormatADF is the Atlassian Document Format page body format. + BodyFormatADF = "adf" + // BodyFormatXHTML is the Confluence storage XHTML page body format. + BodyFormatXHTML = "xhtml" +) // Options controls page-view body projection. type Options struct { @@ -84,21 +92,21 @@ func Project(page *api.Page, spaceKey string, opts Options) (Projection, error) } switch opts.BodyFormat { - case "adf": + case BodyFormatADF: if page.Body != nil && page.Body.AtlasDocFormat != nil { proj.Body, proj.Truncated = TruncateContent(page.Body.AtlasDocFormat.Value, opts) proj.BodyKind = BodyKindADF proj.HasContent = page.Body.AtlasDocFormat.Value != "" } - case "xhtml": + case BodyFormatXHTML: if page.Body != nil && page.Body.Storage != nil { proj.Body, proj.Truncated = TruncateContent(page.Body.Storage.Value, opts) proj.BodyKind = BodyKindXHTML proj.HasContent = page.Body.Storage.Value != "" } - case "", "markdown": + case "", BodyFormatMarkdown: switch { - case hasStorageContent(page): + case HasStorageContent(page): body, truncated, err := projectStorageBody(page.Body.Storage.Value, opts) if err != nil { return Projection{}, err @@ -106,7 +114,7 @@ func Project(page *api.Page, spaceKey string, opts Options) (Projection, error) proj.Body, proj.Truncated = body, truncated proj.BodyKind = BodyKindMarkdown proj.HasContent = true - case hasADFContent(page): + case HasADFContent(page): body, truncated, err := projectADFBody(page.Body.AtlasDocFormat.Value, opts) if err != nil { return Projection{}, err @@ -153,13 +161,15 @@ func TruncateContent(content string, opts Options) (string, bool) { return content, false } -func hasStorageContent(page *api.Page) bool { +// HasStorageContent reports whether page has non-empty storage content. +func HasStorageContent(page *api.Page) bool { return page.Body != nil && page.Body.Storage != nil && page.Body.Storage.Value != "" } -func hasADFContent(page *api.Page) bool { +// HasADFContent reports whether page has non-empty ADF content. +func HasADFContent(page *api.Page) bool { return page.Body != nil && page.Body.AtlasDocFormat != nil && page.Body.AtlasDocFormat.Value != "" From af89c01364d4a904624effba76f279bdb23e97e1 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Fri, 17 Jul 2026 10:06:55 -0400 Subject: [PATCH 06/12] fix(cfl): thread --config path through init and config show (#455) --- tools/cfl/internal/cmd/configcmd/show.go | 2 +- tools/cfl/internal/cmd/configcmd/show_test.go | 16 ++++++++++ tools/cfl/internal/cmd/init/init.go | 2 +- tools/cfl/internal/cmd/init/init_test.go | 29 +++++++++++++++++++ tools/cfl/internal/cmd/root/root.go | 15 ++++++---- 5 files changed, 57 insertions(+), 7 deletions(-) diff --git a/tools/cfl/internal/cmd/configcmd/show.go b/tools/cfl/internal/cmd/configcmd/show.go index 50ee3eac..0d5284bd 100644 --- a/tools/cfl/internal/cmd/configcmd/show.go +++ b/tools/cfl/internal/cmd/configcmd/show.go @@ -35,7 +35,7 @@ command to confirm effective configuration.`, } func runShow(opts *root.Options) error { - configPath := config.DefaultConfigPath() + configPath := opts.ResolvedConfigPath() // Load config file (if exists) fileCfg, fileErr := config.Load(configPath) diff --git a/tools/cfl/internal/cmd/configcmd/show_test.go b/tools/cfl/internal/cmd/configcmd/show_test.go index 1dc82ff7..c5129f04 100644 --- a/tools/cfl/internal/cmd/configcmd/show_test.go +++ b/tools/cfl/internal/cmd/configcmd/show_test.go @@ -81,6 +81,22 @@ func TestRunShow_ExactOutput(t *testing.T) { testutil.Equal(t, "\nConfig file: "+cfgPath+"\n", errBuf.String()) } +func TestRunShow_UsesConfiguredPath(t *testing.T) { + credtest.Hermetic(t) + defaultPath := cflconfig.DefaultConfigPath() + explicitPath := filepath.Join(t.TempDir(), "explicit.yml") + testutil.RequireNoError(t, (&cflconfig.Config{URL: "https://default.atlassian.net/wiki", Email: "default@example.com"}).Save(defaultPath)) + testutil.RequireNoError(t, (&cflconfig.Config{URL: "https://explicit.atlassian.net/wiki", Email: "explicit@example.com"}).Save(explicitPath)) + + out, errBuf := &bytes.Buffer{}, &bytes.Buffer{} + opts := &root.Options{ConfigPath: explicitPath, Output: "table", NoColor: true, Stdout: out, Stderr: errBuf} + testutil.RequireNoError(t, runShow(opts)) + + testutil.Contains(t, out.String(), "URL: https://explicit.atlassian.net/wiki (source: config)") + testutil.NotContains(t, out.String(), "default.atlassian.net") + testutil.Equal(t, "\nConfig file: "+explicitPath+"\n", errBuf.String()) +} + func TestRunShow_UnreadableConfigNote(t *testing.T) { credtest.Hermetic(t) cfgDir := t.TempDir() diff --git a/tools/cfl/internal/cmd/init/init.go b/tools/cfl/internal/cmd/init/init.go index 09685935..c2b57b41 100644 --- a/tools/cfl/internal/cmd/init/init.go +++ b/tools/cfl/internal/cmd/init/init.go @@ -111,7 +111,7 @@ func runInit(ctx context.Context, opts *root.Options, prefillURL, prefillEmail s } } - legacyPath := config.DefaultConfigPath() + legacyPath := opts.ResolvedConfigPath() sharedPath, err := credstore.DefaultPath() if err != nil { v.Error("Cannot resolve the shared credential store path: %v", err) diff --git a/tools/cfl/internal/cmd/init/init_test.go b/tools/cfl/internal/cmd/init/init_test.go index 67c31812..a6199b23 100644 --- a/tools/cfl/internal/cmd/init/init_test.go +++ b/tools/cfl/internal/cmd/init/init_test.go @@ -221,6 +221,35 @@ func TestRunInit_NonInteractive_MissingToken_RecommendsAllPaths(t *testing.T) { } } +func TestRunInit_UsesConfiguredPathForReconciliation(t *testing.T) { + credtest.Hermetic(t) + defaultPath := config.DefaultConfigPath() + explicitPath := filepath.Join(t.TempDir(), "explicit.yml") + testutil.RequireNoError(t, (&config.Config{ + URL: "https://default.atlassian.net/wiki", Email: "default@example.com", DefaultSpace: "DEFAULT", + }).Save(defaultPath)) + testutil.RequireNoError(t, (&config.Config{ + URL: "https://explicit.atlassian.net/wiki", Email: "explicit@example.com", DefaultSpace: "EXPLICIT", + }).Save(explicitPath)) + + opts := &root.Options{ + ConfigPath: explicitPath, + Output: "table", + NoColor: true, + NonInteractive: true, + Stdin: strings.NewReader(cflInitSentinel + "\n"), + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + } + testutil.RequireNoError(t, runInit(context.Background(), opts, + "https://configured.atlassian.net", "configured@example.com", true, "", "", "", true)) + + store, err := credstore.Load(credtest.SharedConfigPath(t)) + testutil.RequireNoError(t, err) + testutil.Equal(t, "EXPLICIT", store.CFL.DefaultSpace) + testutil.Equal(t, "https://configured.atlassian.net", store.Default.URL) +} + // finalizeInit tests use t.TempDir() for paths and an httptest-backed // clientBuilder so the user's real config is never touched and no real // network call is made. diff --git a/tools/cfl/internal/cmd/root/root.go b/tools/cfl/internal/cmd/root/root.go index 9700578b..b7c7b25d 100644 --- a/tools/cfl/internal/cmd/root/root.go +++ b/tools/cfl/internal/cmd/root/root.go @@ -102,17 +102,22 @@ func (o *Options) Config() (*config.Config, error) { return cfg, nil } +// ResolvedConfigPath returns the config path selected by --config, or the +// default path when no explicit value was supplied. +func (o *Options) ResolvedConfigPath() string { + if o.ConfigPath != "" { + return o.ConfigPath + } + return config.DefaultConfigPath() +} + func (o *Options) loadConfig() (*config.Config, error) { if o.cachedConfig != nil { return o.cachedConfig, nil } - path := o.ConfigPath - if path == "" { - path = config.DefaultConfigPath() - } // Defer token resolution until after PersistentPreRunE wires the // backend selected by this same config. - cfg, err := config.LoadWithEnv(path, false) + cfg, err := config.LoadWithEnv(o.ResolvedConfigPath(), false) if err != nil { return nil, err } From ddea625bf5eaf67121bf21207dae19e22be79717 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Fri, 17 Jul 2026 10:13:14 -0400 Subject: [PATCH 07/12] fix(cfl): exact empty bodies, ADF envelope validation, plain-mode docs (#455) --- skills/Confluence/SKILL.md | 2 +- tools/cfl/internal/cmd/page/body_format.go | 8 +++++ .../cfl/internal/cmd/page/body_format_test.go | 5 ++++ tools/cfl/internal/cmd/page/view_test.go | 29 +++++++++++++++++++ tools/cfl/internal/pageview/projection.go | 4 +-- .../cfl/internal/pageview/projection_test.go | 23 +++++++++++++++ tools/cfl/internal/present/detail.go | 5 +++- 7 files changed, 72 insertions(+), 4 deletions(-) diff --git a/skills/Confluence/SKILL.md b/skills/Confluence/SKILL.md index 2932ade8..a4ace145 100644 --- a/skills/Confluence/SKILL.md +++ b/skills/Confluence/SKILL.md @@ -50,7 +50,7 @@ No defaults exist. Ask the user. Do not guess. If the user provides a Confluence - **Page body format** — `--body-format markdown|adf|xhtml` on page view/create/edit. Omission means Markdown; ADF and XHTML are exact. - **Output format** — how it's rendered: `table` (default), `plain` (`-o plain`). For scripted extraction, parse `ID: ` lines from command output. -They combine freely — e.g., `--full -o plain` returns the inspection representation as TSV. +They combine freely — e.g., `cfl page list --full -o plain` returns TSV; detail output in plain mode remains semantically equivalent text. ### Extracting Page IDs from URLs diff --git a/tools/cfl/internal/cmd/page/body_format.go b/tools/cfl/internal/cmd/page/body_format.go index 7dafdf1a..e3ceb3ae 100644 --- a/tools/cfl/internal/cmd/page/body_format.go +++ b/tools/cfl/internal/cmd/page/body_format.go @@ -50,6 +50,14 @@ func bodyForInput(content, format string, legacy bool) (*api.Body, error) { if !json.Valid([]byte(content)) { return nil, fmt.Errorf("invalid ADF JSON") } + var doc struct { + Type string `json:"type"` + Version json.Number `json:"version"` + Content []json.RawMessage `json:"content"` + } + if err := json.Unmarshal([]byte(content), &doc); err != nil || doc.Type != "doc" || doc.Version == "" || doc.Content == nil { + return nil, fmt.Errorf("invalid ADF document: expected an object with type \"doc\", numeric version, and array content") + } return adfBody(content), nil case bodyFormatXHTML: return storageBody(content), nil diff --git a/tools/cfl/internal/cmd/page/body_format_test.go b/tools/cfl/internal/cmd/page/body_format_test.go index 85623c99..6217b115 100644 --- a/tools/cfl/internal/cmd/page/body_format_test.go +++ b/tools/cfl/internal/cmd/page/body_format_test.go @@ -37,6 +37,11 @@ func TestBodyForInput(t *testing.T) { _, err = bodyForInput("{broken", bodyFormatADF, false) testutil.RequireError(t, err) + for _, input := range []string{"null", "[]", "{}", `{"type":"paragraph"}`} { + _, err = bodyForInput(input, bodyFormatADF, false) + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "expected an object with type") + } _, err = bodyForInput(adf, bodyFormatADF, true) testutil.RequireError(t, err) } diff --git a/tools/cfl/internal/cmd/page/view_test.go b/tools/cfl/internal/cmd/page/view_test.go index 1d027433..424678cc 100644 --- a/tools/cfl/internal/cmd/page/view_test.go +++ b/tools/cfl/internal/cmd/page/view_test.go @@ -195,6 +195,35 @@ func TestRunView_ExactOutput_RawContentOnly_NoTruncate(t *testing.T) { testutil.Equal(t, "", rootOpts.Stderr.(*bytes.Buffer).String()) } +func TestRunView_ExactEmptyContentOnly(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + bodyFormat string + apiFormat string + body string + }{ + {"ADF", bodyFormatADF, "atlas_doc_format", `"atlas_doc_format": {"value": ""}`}, + {"XHTML", bodyFormatXHTML, "storage", `"storage": {"value": ""}`}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + testutil.Equal(t, tt.apiFormat, r.URL.Query().Get("body-format")) + _, _ = w.Write([]byte(`{"id":"12345","body":{` + tt.body + `}}`)) + })) + defer server.Close() + + rootOpts := newViewTestRootOptions() + rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) + err := runView(context.Background(), "12345", &viewOptions{Options: rootOpts, bodyFormat: tt.bodyFormat, contentOnly: true}) + testutil.RequireNoError(t, err) + testutil.Equal(t, "", rootOpts.Stdout.(*bytes.Buffer).String()) + }) + } +} + func TestRunView_ExactOutput_DefaultMarkdown_NoTruncate(t *testing.T) { t.Parallel() diff --git a/tools/cfl/internal/pageview/projection.go b/tools/cfl/internal/pageview/projection.go index 78e14589..453778fb 100644 --- a/tools/cfl/internal/pageview/projection.go +++ b/tools/cfl/internal/pageview/projection.go @@ -96,13 +96,13 @@ func Project(page *api.Page, spaceKey string, opts Options) (Projection, error) if page.Body != nil && page.Body.AtlasDocFormat != nil { proj.Body, proj.Truncated = TruncateContent(page.Body.AtlasDocFormat.Value, opts) proj.BodyKind = BodyKindADF - proj.HasContent = page.Body.AtlasDocFormat.Value != "" + proj.HasContent = true } case BodyFormatXHTML: if page.Body != nil && page.Body.Storage != nil { proj.Body, proj.Truncated = TruncateContent(page.Body.Storage.Value, opts) proj.BodyKind = BodyKindXHTML - proj.HasContent = page.Body.Storage.Value != "" + proj.HasContent = true } case "", BodyFormatMarkdown: switch { diff --git a/tools/cfl/internal/pageview/projection_test.go b/tools/cfl/internal/pageview/projection_test.go index 6d1d0662..69030072 100644 --- a/tools/cfl/internal/pageview/projection_test.go +++ b/tools/cfl/internal/pageview/projection_test.go @@ -63,6 +63,29 @@ func TestProject_EmptyContent(t *testing.T) { testutil.False(t, proj.HasContent) } +func TestProject_ExactEmptyBodies(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + format string + body *api.Body + kind BodyKind + }{ + {"ADF", BodyFormatADF, &api.Body{AtlasDocFormat: &api.BodyRepresentation{}}, BodyKindADF}, + {"XHTML", BodyFormatXHTML, &api.Body{Storage: &api.BodyRepresentation{}}, BodyKindXHTML}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + proj, err := Project(&api.Page{Body: tt.body}, "", Options{BodyFormat: tt.format, ContentOnly: true}) + testutil.RequireNoError(t, err) + testutil.Equal(t, "", proj.Body) + testutil.Equal(t, tt.kind, proj.BodyKind) + testutil.True(t, proj.HasContent) + }) + } +} + func TestTruncateContent(t *testing.T) { t.Parallel() long := strings.Repeat("x", MaxChars+10) diff --git a/tools/cfl/internal/present/detail.go b/tools/cfl/internal/present/detail.go index ecef8ff0..b3529f99 100644 --- a/tools/cfl/internal/present/detail.go +++ b/tools/cfl/internal/present/detail.go @@ -103,7 +103,10 @@ func (PagePresenter) PresentView(proj pageview.Projection) *sharedpresent.Output if !proj.ContentOnly { sections = append(sections, stdoutInfo("")) } - sections = append(sections, stdoutInfo(body)) + sections = append(sections, &sharedpresent.MessageSection{ + Message: body, + NoNewline: proj.ContentOnly && proj.HasContent && proj.Body == "" && (proj.BodyKind == pageview.BodyKindADF || proj.BodyKind == pageview.BodyKindXHTML), + }) return &sharedpresent.OutputModel{Sections: sections} } From 5c249cc9485d95f1ede142eb52d214e4e54d7890 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Fri, 17 Jul 2026 10:22:33 -0400 Subject: [PATCH 08/12] fix(cfl): explicit --config skips shared-store layering (#455) --- tools/cfl/internal/cmd/root/root.go | 8 ++-- tools/cfl/internal/cmd/root/root_test.go | 57 ++++++++++++++++++++++++ tools/cfl/internal/config/config.go | 16 ++++--- tools/cfl/internal/config/config_test.go | 4 +- 4 files changed, 73 insertions(+), 12 deletions(-) diff --git a/tools/cfl/internal/cmd/root/root.go b/tools/cfl/internal/cmd/root/root.go index b7c7b25d..b9611452 100644 --- a/tools/cfl/internal/cmd/root/root.go +++ b/tools/cfl/internal/cmd/root/root.go @@ -36,8 +36,9 @@ type Options struct { testClient *api.Client // cachedConfig stores loaded config for reuse - cachedConfig *config.Config - tokenResolved bool + cachedConfig *config.Config + tokenResolved bool + configExplicit bool } // View returns a configured View instance. @@ -117,7 +118,7 @@ func (o *Options) loadConfig() (*config.Config, error) { } // Defer token resolution until after PersistentPreRunE wires the // backend selected by this same config. - cfg, err := config.LoadWithEnv(o.ResolvedConfigPath(), false) + cfg, err := config.LoadWithEnv(o.ResolvedConfigPath(), false, !o.configExplicit) if err != nil { return nil, err } @@ -181,6 +182,7 @@ Get started by running: cfl init`, if err := validateOutputFormat(opts.Output); err != nil { return err } + opts.configExplicit = cmd.Flags().Changed("config") cfg, err := opts.loadConfig() if err != nil { return err diff --git a/tools/cfl/internal/cmd/root/root_test.go b/tools/cfl/internal/cmd/root/root_test.go index 9c44afbe..3e3d7ec3 100644 --- a/tools/cfl/internal/cmd/root/root_test.go +++ b/tools/cfl/internal/cmd/root/root_test.go @@ -9,6 +9,7 @@ import ( "github.com/open-cli-collective/atlassian-go/artifact" "github.com/open-cli-collective/atlassian-go/auth" sharedclient "github.com/open-cli-collective/atlassian-go/client" + "github.com/open-cli-collective/atlassian-go/credstore" "github.com/open-cli-collective/atlassian-go/credtest" "github.com/open-cli-collective/atlassian-go/keyring" "github.com/open-cli-collective/atlassian-go/present" @@ -321,6 +322,15 @@ func TestConfigFlagIsAuthoritative(t *testing.T) { Keyring: config.KeyringConfig{Backend: "file"}, } testutil.RequireNoError(t, defaultCfg.Save(config.DefaultConfigPath())) + testutil.RequireNoError(t, (&credstore.Store{ + Default: credstore.Section{ + URL: "https://shared.atlassian.net", + Email: "shared@example.com", + AuthMethod: auth.AuthMethodBearer, + CloudID: "shared-cloud", + }, + CFL: credstore.ToolSection{DefaultSpace: "SHARED", OutputFormat: "shared-output"}, + }).Save(credtest.SharedConfigPath(t))) explicitPath := filepath.Join(t.TempDir(), "explicit.yml") explicitCfg := &config.Config{ @@ -380,3 +390,50 @@ func TestConfigFlagIsAuthoritative(t *testing.T) { }) } } + +func TestDefaultConfigLayersSharedValues(t *testing.T) { + credtest.Hermetic(t) + keyring.SetBackendSelection("", "") + t.Cleanup(func() { keyring.SetBackendSelection("", "") }) + t.Setenv("CFL_API_TOKEN", "token") + + legacy := &config.Config{ + URL: "https://legacy.atlassian.net/wiki", + Email: "legacy@example.com", + AuthMethod: auth.AuthMethodBasic, + CloudID: "legacy-cloud", + DefaultSpace: "LEGACY", + OutputFormat: "legacy-output", + } + testutil.RequireNoError(t, legacy.Save(config.DefaultConfigPath())) + testutil.RequireNoError(t, (&credstore.Store{ + Default: credstore.Section{ + URL: "https://shared.atlassian.net", + Email: "shared@example.com", + AuthMethod: auth.AuthMethodBearer, + CloudID: "shared-cloud", + }, + CFL: credstore.ToolSection{DefaultSpace: "SHARED", OutputFormat: "shared-output"}, + }).Save(credtest.SharedConfigPath(t))) + + rootCmd, opts := NewCmd() + rootCmd.AddCommand(&cobra.Command{ + Use: "probe", + RunE: func(*cobra.Command, []string) error { + cfg, err := opts.Config() + if err != nil { + return err + } + if cfg.URL != "https://shared.atlassian.net/wiki" || cfg.Email != "shared@example.com" || + cfg.AuthMethod != auth.AuthMethodBearer || cfg.CloudID != "shared-cloud" || + cfg.DefaultSpace != "SHARED" || cfg.OutputFormat != "shared-output" { + t.Fatal("default config did not layer shared values") + } + return nil + }, + }) + rootCmd.SetArgs([]string{"probe"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("Execute failed: %v", err) + } +} diff --git a/tools/cfl/internal/config/config.go b/tools/cfl/internal/config/config.go index 3e1550f2..2c35f9bf 100644 --- a/tools/cfl/internal/config/config.go +++ b/tools/cfl/internal/config/config.go @@ -243,7 +243,7 @@ func warnCorruptSharedOnce(err error) { // + env so a broken shared file doesn't crash every cfl command. Init // uses credstore.Load directly so it can surface the error and refuse // to overwrite. -func LoadWithEnv(path string, resolveToken bool) (*Config, error) { +func LoadWithEnv(path string, resolveToken, loadShared bool) (*Config, error) { cfg, err := Load(path) if err != nil { // Legacy file missing or corrupt: start empty. cfl init has a @@ -259,12 +259,14 @@ func LoadWithEnv(path string, resolveToken bool) (*Config, error) { // command keeps working while the conflict is surfaced once. A nil // store (unresolvable/corrupt) → fall back to legacy + env. `cfl // init` uses the fail-loud detect/gated-copy path instead. - store, sErr := credstore.LoadSharedRuntime() - if sErr != nil { - warnCorruptSharedOnce(sErr) - } - if store != nil { - cfg.LoadFromShared(store) + if loadShared { + store, sErr := credstore.LoadSharedRuntime() + if sErr != nil { + warnCorruptSharedOnce(sErr) + } + if store != nil { + cfg.LoadFromShared(store) + } } cfg.LoadFromEnv() diff --git a/tools/cfl/internal/config/config_test.go b/tools/cfl/internal/config/config_test.go index ed603b2b..9531b2d5 100644 --- a/tools/cfl/internal/config/config_test.go +++ b/tools/cfl/internal/config/config_test.go @@ -557,7 +557,7 @@ func TestLoadWithEnv_PrecedenceLegacyToSharedToEnv(t *testing.T) { // Set CFL_API_TOKEN env (highest precedence). t.Setenv("CFL_API_TOKEN", "env-tok") - cfg, err := LoadWithEnv(legacyPath, true) + cfg, err := LoadWithEnv(legacyPath, true, true) testutil.RequireNoError(t, err) // URL: shared wins over legacy (env not set for URL). @@ -585,7 +585,7 @@ func TestLoadWithEnv_CorruptSharedFallsBackToLegacy(t *testing.T) { legacy := &Config{URL: "https://x.atlassian.net/wiki", Email: "e@x", APIToken: "t"} testutil.RequireNoError(t, legacy.Save(legacyPath)) - cfg, err := LoadWithEnv(legacyPath, true) + cfg, err := LoadWithEnv(legacyPath, true, true) testutil.RequireNoError(t, err) testutil.Equal(t, "https://x.atlassian.net/wiki", cfg.URL) // Corrupt shared store defers migration; keyring is empty → no token, From 60ab320bcf19c8e5777f7ef2910cf12e466f632f Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Fri, 17 Jul 2026 10:26:05 -0400 Subject: [PATCH 09/12] fix(cfl): exact body formats bypass truncation (#455) --- skills/Confluence/CliReference.md | 4 +- skills/Confluence/Workflows/ViewPage.md | 12 +++--- tools/cfl/README.md | 4 +- tools/cfl/internal/cmd/OUTPUT_SPEC.md | 4 +- tools/cfl/internal/cmd/page/view.go | 7 ++-- tools/cfl/internal/cmd/page/view_test.go | 42 ++++++++++++++----- tools/cfl/internal/pageview/projection.go | 2 +- .../cfl/internal/pageview/projection_test.go | 32 ++++++++++++++ 8 files changed, 80 insertions(+), 27 deletions(-) diff --git a/skills/Confluence/CliReference.md b/skills/Confluence/CliReference.md index 6cdf7fcd..6336a0c4 100644 --- a/skills/Confluence/CliReference.md +++ b/skills/Confluence/CliReference.md @@ -77,9 +77,9 @@ cfl [resource] [action] [ID] [flags] | Flag | Description | |------|-------------| -| `--no-truncate` | Show full content without truncation | +| `--no-truncate` | Show full Markdown content without truncation; exact ADF/XHTML are always complete | | `--content-only` | Output only page content (no metadata headers); implies `--no-truncate` | -| `--body-format markdown\|adf\|xhtml` | Body representation; ADF and XHTML are emitted exactly | +| `--body-format markdown\|adf\|xhtml` | Body representation; ADF and XHTML are emitted exactly and never truncated | | `--show-macros` | Show macro placeholders (e.g. `[TOC]`) instead of stripping them | | `-w, --web` | Open in browser | diff --git a/skills/Confluence/Workflows/ViewPage.md b/skills/Confluence/Workflows/ViewPage.md index a62e22e5..3b35569b 100644 --- a/skills/Confluence/Workflows/ViewPage.md +++ b/skills/Confluence/Workflows/ViewPage.md @@ -8,7 +8,7 @@ View Confluence page content and metadata. See SKILL.md "Output Representation and Format" for artifact breadth and page-body representation. -The default Markdown body is truncated at 5000 chars. This also applies to ADF, XHTML, and `--show-macros`. Use `--no-truncate`, or `--content-only` (which implies it), for the complete body. +The default Markdown body is truncated at 5000 chars; `--show-macros` follows the same rule. Exact ADF and XHTML bodies are never truncated. Use `--no-truncate`, or `--content-only` (which implies it), for complete Markdown output. `--content-only` already implies `--no-truncate`; don't combine them. @@ -19,8 +19,8 @@ The default Markdown body is truncated at 5000 chars. This also applies to ADF, | "view page", "show page", "read page" | `cfl page view PAGE_ID` | Default markdown view (subject to truncation) | | "show full page", "all content", "no truncation" | `cfl page view PAGE_ID --no-truncate` | Full content without truncation | | "just the content", "content only" | `cfl page view PAGE_ID --content-only` | Content without metadata headers (implies `--no-truncate`) | -| "XHTML", "storage format" | `cfl page view PAGE_ID --body-format xhtml` | Exact storage XHTML (subject to truncation) | -| "ADF", "Atlassian document format" | `cfl page view PAGE_ID --body-format adf` | Exact ADF JSON (subject to truncation) | +| "XHTML", "storage format" | `cfl page view PAGE_ID --body-format xhtml` | Complete exact storage XHTML | +| "ADF", "Atlassian document format" | `cfl page view PAGE_ID --body-format adf` | Complete exact ADF JSON | | "show macros" | `cfl page view PAGE_ID --show-macros` | Preserve macro placeholders like `[TOC]` (subject to truncation) | | "open in browser", "open page" | `cfl page view PAGE_ID --web` | Opens in default browser | @@ -50,11 +50,11 @@ cfl page view PAGE_ID --content-only # Preserve macros that would otherwise be stripped cfl page view PAGE_ID --show-macros -# Exact storage XHTML — also subject to default truncation +# Exact storage XHTML — never truncated cfl page view PAGE_ID --body-format xhtml -# Full exact ADF JSON (no truncation) -cfl page view PAGE_ID --body-format adf --no-truncate +# Exact ADF JSON — never truncated +cfl page view PAGE_ID --body-format adf # Open in browser cfl page view PAGE_ID --web diff --git a/tools/cfl/README.md b/tools/cfl/README.md index f3d1904e..fd499684 100644 --- a/tools/cfl/README.md +++ b/tools/cfl/README.md @@ -296,10 +296,10 @@ cfl page view 12345 --show-macros --content-only | cfl page edit 12345 --legacy | Flag | Short | Default | Description | |------|-------|---------|-------------| -| `--body-format` | | `markdown` | Body representation: `markdown`, exact `adf` JSON, or exact storage `xhtml` | +| `--body-format` | | `markdown` | Body representation: `markdown`, exact `adf` JSON, or exact storage `xhtml` (exact formats are never truncated) | | `--web` | `-w` | `false` | Open page in browser instead of displaying | | `--version` | | `0` | View a specific page version | -| `--no-truncate` | | `false` | Show full content without truncation | +| `--no-truncate` | | `false` | Show full Markdown content without truncation | | `--show-macros` | | `false` | Show Confluence macro placeholders (e.g., `[TOC]`) instead of stripping them | | `--content-only` | | `false` | Output only page content (no Title/ID/Version headers); implies `--no-truncate` | diff --git a/tools/cfl/internal/cmd/OUTPUT_SPEC.md b/tools/cfl/internal/cmd/OUTPUT_SPEC.md index 51e9bdcf..acdf6ffe 100644 --- a/tools/cfl/internal/cmd/OUTPUT_SPEC.md +++ b/tools/cfl/internal/cmd/OUTPUT_SPEC.md @@ -204,10 +204,10 @@ Author ID: Flag-specific behavior: - `--content-only` emits only the body and implies untruncated output. -- `--body-format` selects Markdown (default), exact ADF JSON, or exact storage XHTML. +- `--body-format` selects Markdown (default), exact ADF JSON, or exact storage XHTML; exact ADF and XHTML are never truncated. - Markdown conversion errors fail with no body on stdout and suggest an exact format. - `--show-macros` affects body conversion only. -- `--no-truncate` disables the default body truncation guard. +- `--no-truncate` disables the default Markdown body truncation guard. - `--version N` selects a historical version and preserves the same output shape. - `--web` opens the browser and emits no CLI body text. diff --git a/tools/cfl/internal/cmd/page/view.go b/tools/cfl/internal/cmd/page/view.go index 9a05ef5b..96296662 100644 --- a/tools/cfl/internal/cmd/page/view.go +++ b/tools/cfl/internal/cmd/page/view.go @@ -42,8 +42,9 @@ The page body is displayed as Markdown by default. Use --body-format adf for exact Atlassian Document Format JSON or --body-format xhtml for exact Confluence storage XHTML. -By default, output is truncated to 5000 characters for concise display. -Use --no-truncate to show the complete page content without truncation. +Markdown output is truncated to 5000 characters by default for concise display. +Exact ADF and XHTML output is never truncated. Use --no-truncate to show the +complete Markdown page content without truncation. The --content-only flag implies --no-truncate since it is intended for piping.`, Example: ` # View a page (markdown, truncated if large) cfl page view 12345 @@ -89,7 +90,7 @@ The --content-only flag implies --no-truncate since it is intended for piping.`, cmd.Flags().StringVar(&opts.bodyFormat, "body-format", bodyFormatMarkdown, "Body format: markdown, adf, or xhtml") cmd.Flags().BoolVarP(&opts.web, "web", "w", false, "Open in browser instead of displaying") - cmd.Flags().BoolVar(&opts.noTruncate, "no-truncate", false, "Show full content without truncation") + cmd.Flags().BoolVar(&opts.noTruncate, "no-truncate", false, "Show full Markdown content without truncation") cmd.Flags().BoolVar(&opts.showMacros, "show-macros", false, "Show Confluence macro placeholders (e.g., [TOC]) instead of stripping them") cmd.Flags().BoolVar(&opts.contentOnly, "content-only", false, "Output only page content (no metadata headers); implies --no-truncate") cmd.Flags().IntVar(&opts.version, "version", 0, "View a specific page version") diff --git a/tools/cfl/internal/cmd/page/view_test.go b/tools/cfl/internal/cmd/page/view_test.go index 424678cc..1a2ec37e 100644 --- a/tools/cfl/internal/cmd/page/view_test.go +++ b/tools/cfl/internal/cmd/page/view_test.go @@ -164,7 +164,7 @@ func TestRunView_ExactOutput_Raw(t *testing.T) { testutil.Equal(t, "", rootOpts.Stderr.(*bytes.Buffer).String()) } -func TestRunView_ExactOutput_RawContentOnly_NoTruncate(t *testing.T) { +func TestRunView_ExactXHTMLBypassesTruncation(t *testing.T) { t.Parallel() content := "

" + strings.Repeat("a", maxViewChars+10) + "

" @@ -184,17 +184,37 @@ func TestRunView_ExactOutput_RawContentOnly_NoTruncate(t *testing.T) { rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) err := runView(context.Background(), "12345", &viewOptions{ - Options: rootOpts, - bodyFormat: bodyFormatXHTML, - contentOnly: true, - noTruncate: true, + Options: rootOpts, + bodyFormat: bodyFormatXHTML, }) testutil.RequireNoError(t, err) - testutil.Equal(t, content+"\n", rootOpts.Stdout.(*bytes.Buffer).String()) + testutil.Equal(t, "Title: Long Page\nID: 12345\nVersion: 1\n\n"+content+"\n", rootOpts.Stdout.(*bytes.Buffer).String()) testutil.Equal(t, "", rootOpts.Stderr.(*bytes.Buffer).String()) } +func TestRunView_ExactADFBypassesTruncation(t *testing.T) { + t.Parallel() + + content := `{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"text","text":"` + strings.Repeat("a", maxViewChars) + `"}]}]}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(api.Page{ + ID: "12345", Title: "Long ADF Page", Version: &api.Version{Number: 1}, + Body: &api.Body{AtlasDocFormat: &api.BodyRepresentation{Representation: "atlas_doc_format", Value: content}}, + }) + })) + defer server.Close() + + rootOpts := newViewTestRootOptions() + rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) + testutil.RequireNoError(t, runView(context.Background(), "12345", &viewOptions{Options: rootOpts, bodyFormat: bodyFormatADF})) + + _, body, found := strings.Cut(rootOpts.Stdout.(*bytes.Buffer).String(), "\n\n") + testutil.True(t, found) + testutil.Equal(t, content+"\n", body) + testutil.True(t, json.Valid([]byte(body))) +} + func TestRunView_ExactEmptyContentOnly(t *testing.T) { t.Parallel() @@ -662,7 +682,7 @@ func TestRunView_VersionNewerThanCurrent_PreservesVersionContext(t *testing.T) { testutil.NotContains(t, err.Error(), "getting page: page version") } -func TestRunView_VersionTruncatesByDefault(t *testing.T) { +func TestRunView_VersionMarkdownTruncatesByDefault(t *testing.T) { t.Parallel() server := mockVersionedViewServer(t, "

"+strings.Repeat("a", maxViewChars+10)+"

") defer server.Close() @@ -671,14 +691,14 @@ func TestRunView_VersionTruncatesByDefault(t *testing.T) { rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) opts := &viewOptions{ - Options: rootOpts, - version: 2, - bodyFormat: bodyFormatXHTML, + Options: rootOpts, + version: 2, } err := runView(context.Background(), "12345", opts) testutil.RequireNoError(t, err) - testutil.Contains(t, rootOpts.Stdout.(*bytes.Buffer).String(), "truncated at") + stdout := rootOpts.Stdout.(*bytes.Buffer).String() + testutil.Contains(t, stdout, "truncated at") } func TestRunView_VersionNoTruncate(t *testing.T) { diff --git a/tools/cfl/internal/pageview/projection.go b/tools/cfl/internal/pageview/projection.go index 453778fb..481797b2 100644 --- a/tools/cfl/internal/pageview/projection.go +++ b/tools/cfl/internal/pageview/projection.go @@ -151,7 +151,7 @@ func projectADFBody(content string, opts Options) (body string, truncated bool, // TruncateContent truncates content if it exceeds the character limit and // reports whether truncation occurred. func TruncateContent(content string, opts Options) (string, bool) { - if opts.NoTruncate || opts.ContentOnly { + if opts.NoTruncate || opts.ContentOnly || opts.BodyFormat == BodyFormatADF || opts.BodyFormat == BodyFormatXHTML { return content, false } runes := []rune(content) diff --git a/tools/cfl/internal/pageview/projection_test.go b/tools/cfl/internal/pageview/projection_test.go index 69030072..22ed5d01 100644 --- a/tools/cfl/internal/pageview/projection_test.go +++ b/tools/cfl/internal/pageview/projection_test.go @@ -1,6 +1,7 @@ package pageview import ( + "encoding/json" "errors" "strings" "testing" @@ -96,4 +97,35 @@ func TestTruncateContent(t *testing.T) { full, fullTruncated := TruncateContent(long, Options{ContentOnly: true}) testutil.Equal(t, long, full) testutil.False(t, fullTruncated) + + for _, format := range []string{BodyFormatADF, BodyFormatXHTML} { + full, fullTruncated = TruncateContent(long, Options{BodyFormat: format}) + testutil.Equal(t, long, full) + testutil.False(t, fullTruncated) + } +} + +func TestProject_ExactLargeBodiesBypassTruncation(t *testing.T) { + t.Parallel() + + adf := `{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"text","text":"` + strings.Repeat("a", MaxChars) + `"}]}]}` + xhtml := "

" + strings.Repeat("a", MaxChars+10) + "

" + for _, tt := range []struct { + name string + format string + body *api.Body + }{ + {"ADF", BodyFormatADF, &api.Body{AtlasDocFormat: &api.BodyRepresentation{Value: adf}}}, + {"XHTML", BodyFormatXHTML, &api.Body{Storage: &api.BodyRepresentation{Value: xhtml}}}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + proj, err := Project(&api.Page{Body: tt.body}, "", Options{BodyFormat: tt.format}) + testutil.RequireNoError(t, err) + testutil.False(t, proj.Truncated) + if tt.format == BodyFormatADF { + testutil.True(t, json.Valid([]byte(proj.Body))) + } + }) + } } From d87e0a92d05e6d67b51dcaea2c694f547a9b9c43 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Fri, 17 Jul 2026 10:27:53 -0400 Subject: [PATCH 10/12] docs(cfl): correct Go prerequisite and document bin/cfl invocation (#455) --- tools/cfl/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/cfl/README.md b/tools/cfl/README.md index 39e6bcb3..936275d0 100644 --- a/tools/cfl/README.md +++ b/tools/cfl/README.md @@ -129,9 +129,12 @@ sudo rpm -i cfl-VERSION.x86_64.rpm git clone https://github.com/open-cli-collective/atlassian-cli.git cd atlassian-cli make build-cfl +./bin/cfl --version ``` -Requires Go 1.24 or later. (`go install` of the old `confluence-cli` module path installs a stale pre-monorepo binary; use Homebrew or a clone instead.) +The binary is written to `bin/cfl`; invoke it as `./bin/cfl` or copy it somewhere on your `PATH` (e.g. `install bin/cfl /usr/local/bin/`). + +Requires Go 1.26 or later. (`go install` of the old `confluence-cli` module path installs a stale pre-monorepo binary; use Homebrew or a clone instead.) ## Quick Start From 8fbd5ad1af10ace7ee0cff55d5d360c97f13a539 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Fri, 17 Jul 2026 11:08:47 -0400 Subject: [PATCH 11/12] fix(cfl): omit absent --full fields, correct scripting and --full docs (#455) --- skills/Confluence/SKILL.md | 2 +- skills/Confluence/Workflows/SearchPages.md | 15 +++++---------- tools/cfl/README.md | 2 +- tools/cfl/internal/cmd/page/view_test.go | 20 ++++++++++++++++++++ tools/cfl/internal/present/detail.go | 14 +++++++++----- tools/cfl/internal/present/detail_test.go | 15 +++++++++++++++ 6 files changed, 51 insertions(+), 17 deletions(-) diff --git a/skills/Confluence/SKILL.md b/skills/Confluence/SKILL.md index 78464461..f1a5c875 100644 --- a/skills/Confluence/SKILL.md +++ b/skills/Confluence/SKILL.md @@ -49,7 +49,7 @@ No defaults exist. Ask the user. Do not guess. If the user provides a Confluence - `agent` (default) — curated, action-oriented, LLM-optimized - `full` (`--full`) — inspection-oriented additions on page/space views and supported list/search commands - **Page body format** — `--body-format markdown|adf|xhtml` on page view/create/edit. Omission means Markdown; ADF and XHTML are exact. -- **Output format** — how it's rendered: `table` (default) or `plain` (`-o plain`). For scripted extraction, parse `ID: ` lines from command output. +- **Output format** — how it's rendered: `table` (default) or `plain` (`-o plain`). Detail and mutation commands expose IDs as `ID: ` lines; for list/search commands, use `-o plain` TSV and read the first (`ID`) column. They combine freely — e.g., `cfl page list --full -o plain` returns TSV; detail output in plain mode remains semantically equivalent text. `page view --full` composes with every body format, but not with `--content-only` or `--web`. diff --git a/skills/Confluence/Workflows/SearchPages.md b/skills/Confluence/Workflows/SearchPages.md index 3fe0e6aa..ef0f6de2 100644 --- a/skills/Confluence/Workflows/SearchPages.md +++ b/skills/Confluence/Workflows/SearchPages.md @@ -63,20 +63,15 @@ Use a positive `--limit N` to control result count (default 25). ### Scripting / Parsing Output -When the next step depends on extracting a page ID from the results, request JSON output. With `-o json`, the output has this structure: - -```json -{ - "results": [ - { "id": "...", "title": "...", "type": "page", "spaceName": "...", "excerpt": "..." } - ], - "_meta": { "count": 0, "hasMore": false } -} +When the next step depends on extracting a page ID from the results, use `-o plain`. List and search output is TSV with a header row; the first column is `ID`. + +```bash +cfl search --title "Release plan" --space DEV -o plain | awk -F '\t' 'NR > 1 { print $1 }' ``` The `--title` filter does substring matching, so multiple pages may be returned — narrow with `--space` when you need a single result. -Avoid pattern-matching against default `table` output — it's human-oriented and layout may change. Always use `-o json` when a downstream step parses the response. +Avoid pattern-matching against default `table` output — it's human-oriented and layout may change. Use `-o plain` TSV when a downstream step parses the response. ## Output Format diff --git a/tools/cfl/README.md b/tools/cfl/README.md index 936275d0..69dc92da 100644 --- a/tools/cfl/README.md +++ b/tools/cfl/README.md @@ -191,7 +191,7 @@ These flags are available on all commands: | `--config` | `-c` | `~/.config/cfl/config.yml` | Path to config file | | `--output` | `-o` | `table` | Output format: `table`, `plain` | | `--no-color` | | `false` | Disable colored output | -| `--full` | | `false` | Inspection-oriented output; supported on view, list, and search commands (rejected elsewhere) | +| `--full` | | `false` | Inspection-oriented output; supported only by `page list`, `page view`, `space list`, `space view`, `attachment list`, and `search` (rejected elsewhere) | | `--help` | `-h` | | Show help for command | | `--version` | `-v` | | Show version (root command only) | diff --git a/tools/cfl/internal/cmd/page/view_test.go b/tools/cfl/internal/cmd/page/view_test.go index c03780b6..84f4749f 100644 --- a/tools/cfl/internal/cmd/page/view_test.go +++ b/tools/cfl/internal/cmd/page/view_test.go @@ -145,6 +145,26 @@ func TestRunView_FullMetadataAcrossBodyFormats(t *testing.T) { } } +func TestRunView_FullOmitsAbsentMetadata(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + testutil.Equal(t, "storage", r.URL.Query().Get("body-format")) + _, _ = w.Write([]byte(`{ + "id":"12345","title":"Root Page","version":{"number":3}, + "body":{"storage":{"value":"

Hello

"}} + }`)) + })) + defer server.Close() + + rootOpts := newViewTestRootOptions() + rootOpts.Full = true + rootOpts.SetAPIClient(api.NewClient(server.URL, "test@example.com", "token")) + + err := runView(context.Background(), "12345", &viewOptions{Options: rootOpts}) + testutil.RequireNoError(t, err) + testutil.Equal(t, "Title: Root Page\nID: 12345\nVersion: 3\n\nHello\n", rootOpts.Stdout.(*bytes.Buffer).String()) +} + func TestRunView_FullContentOnlyFailsBeforeNetwork(t *testing.T) { t.Parallel() requests := 0 diff --git a/tools/cfl/internal/present/detail.go b/tools/cfl/internal/present/detail.go index f3cb1bed..351101a4 100644 --- a/tools/cfl/internal/present/detail.go +++ b/tools/cfl/internal/present/detail.go @@ -97,11 +97,15 @@ func (PagePresenter) PresentView(proj pageview.Projection) *sharedpresent.Output }) } if proj.Full { - fields = append(fields, - sharedpresent.Field{Label: "Parent ID", Value: orDash(proj.ParentID)}, - sharedpresent.Field{Label: "Created At", Value: formatHistoryTime(proj.CreatedAt)}, - sharedpresent.Field{Label: "Author ID", Value: orDash(proj.AuthorID)}, - ) + if proj.ParentID != "" { + fields = append(fields, sharedpresent.Field{Label: "Parent ID", Value: proj.ParentID}) + } + if proj.CreatedAt != nil && !proj.CreatedAt.IsZero() { + fields = append(fields, sharedpresent.Field{Label: "Created At", Value: formatHistoryTime(proj.CreatedAt)}) + } + if proj.AuthorID != "" { + fields = append(fields, sharedpresent.Field{Label: "Author ID", Value: proj.AuthorID}) + } } sections = append(sections, &sharedpresent.DetailSection{Fields: fields}) } diff --git a/tools/cfl/internal/present/detail_test.go b/tools/cfl/internal/present/detail_test.go index 8ee002d5..7683715c 100644 --- a/tools/cfl/internal/present/detail_test.go +++ b/tools/cfl/internal/present/detail_test.go @@ -136,6 +136,21 @@ func TestPagePresenter_PresentView_Default(t *testing.T) { testutil.Equal(t, "Hello world", body.Message) } +func TestPagePresenter_PresentView_FullOmitsAbsentMetadata(t *testing.T) { + t.Parallel() + + model := PagePresenter{}.PresentView(pageview.Projection{ + Title: "Root Page", ID: "12345", Full: true, + Body: "Hello world", BodyKind: pageview.BodyKindMarkdown, HasContent: true, + }) + + detail := requireDetailSection(t, model, 0) + testutil.Equal(t, []sharedpresent.Field{ + {Label: "Title", Value: "Root Page"}, + {Label: "ID", Value: "12345"}, + }, detail.Fields) +} + func TestPagePresenter_PresentView_ContentOnlyXHTML(t *testing.T) { t.Parallel() From 655b85c00bd76449bbaedef6dfac8da559ca1182 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Fri, 17 Jul 2026 13:24:08 -0400 Subject: [PATCH 12/12] fix(cfl): restore #459 final-round changes clobbered by -X ours reconciliation (#455) --- skills/Confluence/CliReference.md | 4 ++-- tools/cfl/internal/present/detail.go | 2 +- tools/cfl/scripts/roundtrip-test.sh | 7 +++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/skills/Confluence/CliReference.md b/skills/Confluence/CliReference.md index 98d1955b..eda591c0 100644 --- a/skills/Confluence/CliReference.md +++ b/skills/Confluence/CliReference.md @@ -46,7 +46,7 @@ cfl [resource] [action] [ID] [flags] | `cfl page view PAGE_ID --content-only` | Output content only (no metadata headers); implies `--no-truncate` | | `cfl page view PAGE_ID --body-format xhtml` | View exact Confluence storage XHTML | | `cfl page view PAGE_ID --body-format adf` | View exact ADF JSON | -| `cfl page view PAGE_ID --show-macros` | Show macro placeholders (e.g. `[TOC]`) instead of stripping them | +| `cfl page view PAGE_ID --show-macros` | Show macro placeholders (e.g. `[TOC]`) instead of stripping them (Markdown-only; rejected with `--body-format adf|xhtml`) | | `cfl page view PAGE_ID --web` | Open page in browser | | `cfl page create --space KEY --title "TEXT"` | Create page (opens editor) | | `cfl page create --space KEY --title "TEXT" --file content.md` | Create from file | @@ -81,7 +81,7 @@ cfl [resource] [action] [ID] [flags] | `--no-truncate` | Show full Markdown content without truncation; exact ADF/XHTML are always complete | | `--content-only` | Output only page content (no metadata headers); implies `--no-truncate` | | `--body-format markdown\|adf\|xhtml` | Body representation; ADF and XHTML are emitted exactly and never truncated | -| `--show-macros` | Show macro placeholders (e.g. `[TOC]`) instead of stripping them | +| `--show-macros` | Show macro placeholders (e.g. `[TOC]`) instead of stripping them. Markdown-only — rejected with `--body-format adf|xhtml` | | `-w, --web` | Open in browser | `--full` composes with `--body-format` and is incompatible with `--content-only` and `--web`. diff --git a/tools/cfl/internal/present/detail.go b/tools/cfl/internal/present/detail.go index 351101a4..dc6b913c 100644 --- a/tools/cfl/internal/present/detail.go +++ b/tools/cfl/internal/present/detail.go @@ -116,7 +116,7 @@ func (PagePresenter) PresentView(proj pageview.Projection) *sharedpresent.Output } sections = append(sections, &sharedpresent.MessageSection{ Message: body, - NoNewline: proj.ContentOnly && proj.HasContent && proj.Body == "" && (proj.BodyKind == pageview.BodyKindADF || proj.BodyKind == pageview.BodyKindXHTML), + NoNewline: proj.ContentOnly && (proj.BodyKind == pageview.BodyKindADF || proj.BodyKind == pageview.BodyKindXHTML), }) return &sharedpresent.OutputModel{Sections: sections} diff --git a/tools/cfl/scripts/roundtrip-test.sh b/tools/cfl/scripts/roundtrip-test.sh index 14997005..89b95975 100755 --- a/tools/cfl/scripts/roundtrip-test.sh +++ b/tools/cfl/scripts/roundtrip-test.sh @@ -138,11 +138,10 @@ process_page() { # Step 1: Fetch page and check format # Capture raw content first to distinguish fetch failures from ADF pages - local raw_content + local raw_content xhtml_error="" if ! raw_content=$(cfl page view "$id" --body-format xhtml --content-only 2>&1); then - echo "[$id] FAIL: Could not fetch page: $raw_content" - FAIL=$((FAIL + 1)) - return 1 + xhtml_error="$raw_content" + raw_content="" fi # Explicit XHTML does not fall back to ADF, so probe ADF before failing.