Skill: snowsand-enterprises/snowsand-confluence (version 1.0.0)

--- name: snowsand-confluence version: 1.0.0 description: Interact with Confluence Cloud via REST API. Use for space management, page operations (list, view, create, update, delete), content search (CQL queries), page tree navigation (parent/child), attachments, comments, and labels. Triggers on Confluence operations, wiki pages, documentation tasks, or any Atlassian Confluence Cloud task. --- # Confluence Cloud Integration Confluence Cloud REST API v2 integration for wiki/documentation management, including spaces, pages, attachments, comments, and labels. ## Authentication Confluence Cloud uses API token authentication. Required environment variables: - `CONFLUENCE_BASE_URL` - Your Confluence instance (e.g., `https://yourcompany.atlassian.net`) - `CONFLUENCE_USER_EMAIL` - Atlassian account email - `CONFLUENCE_API_TOKEN` - API token from https://id.atlassian.com/manage-profile/security/api-tokens Test connection: ```bash curl -s -u "$CONFLUENCE_USER_EMAIL:$CONFLUENCE_API_TOKEN" "$CONFLUENCE_BASE_URL/wiki/api/v2/spaces?limit=1" | jq . ``` ## Quick Reference All operations use the `scripts/confluence.py` script: | Operation | Command | |-----------|---------| | **Spaces** | | | List spaces | `confluence.py spaces` | | Get space by ID | `confluence.py space SPACE_ID` | | Get space by key | `confluence.py space-by-key PROJ` | | Create space | `confluence.py create-space --name "My Space" --key MYSP` | | **Pages** | | | List pages | `confluence.py pages --space-id SPACE_ID` | | Get page | `confluence.py page PAGE_ID` | | Create page | `confluence.py create-page --space-id ID --title "Title" --body "<p>Content</p>"` | | Update page | `confluence.py update-page PAGE_ID --body "<p>New content</p>"` | | Delete page | `confluence.py delete-page PAGE_ID` | | **Page Tree** | | | Get children | `confluence.py children PAGE_ID` | | Get ancestors | `confluence.py ancestors PAGE_ID` | | **Search** | | | CQL search | `confluence.py search "type=page AND space=PROJ"` | | **Attachments** | | | List attachments | `confluence.py attachments PAGE_ID` | | Get attachment | `confluence.py attachment ATT_ID` | | Upload file | `confluence.py upload PAGE_ID /path/to/file.pdf` | | Download | `confluence.py download ATT_ID -o output.pdf` | | Delete attachment | `confluence.py delete-attachment ATT_ID` | | **Comments** | | | List comments | `confluence.py comments PAGE_ID` | | Get comment | `confluence.py comment COMMENT_ID` | | Create comment | `confluence.py create-comment PAGE_ID "Comment text"` | | Update comment | `confluence.py update-comment COMMENT_ID "New text"` | | Delete comment | `confluence.py delete-comment COMMENT_ID` | | **Labels** | | | Get labels | `confluence.py labels PAGE_ID` | | Add labels | `confluence.py add-labels PAGE_ID "label1,label2"` | | Remove label | `confluence.py remove-label PAGE_ID labelname` | | **User** | | | Current user | `confluence.py me` | ## Common Workflows ### Space Management ```bash # List all spaces confluence.py spaces # List global spaces only confluence.py spaces --type global # Get space by key confluence.py space-by-key PROJ # Create a new space confluence.py create-space --name "Project Documentation" --key DOCS --description "Team docs" # Create a private space confluence.py create-space --name "Private Notes" --key PRIV --private ``` ### Page Operations ```bash # List pages in a space (need space ID, not key) # First get the space ID: confluence.py space-by-key PROJ # Then list pages: confluence.py pages --space-id 12345678 # Filter pages by title confluence.py pages --title "Meeting Notes" # Get page with body content confluence.py page 98765432 --body-format storage # Get page with labels confluence.py page 98765432 --include-labels # Get specific version confluence.py page 98765432 --version 5 ``` ### Creating Pages Pages use Confluence Storage Format (XHTML-based): ```bash # Simple page confluence.py create-page --space-id 12345678 --title "New Page" \ --body "<p>Hello World</p>" # Page with formatting confluence.py create-page --space-id 12345678 --title "Formatted Page" \ --body "<h1>Heading</h1><p>Paragraph with <strong>bold</strong> text.</p><ul><li>Item 1</li><li>Item 2</li></ul>" # Child page (under a parent) confluence.py create-page --space-id 12345678 --title "Child Page" \ --parent-id 98765432 --body "<p>This is a child page</p>" # Draft page confluence.py create-page --space-id 12345678 --title "Draft" \ --status draft --body "<p>Work in progress</p>" ``` ### Updating Pages ```bash # Update page content confluence.py update-page 98765432 --body "<p>Updated content</p>" # Update with new title confluence.py update-page 98765432 --title "New Title" --body "<p>Content</p>" # Update with version message confluence.py update-page 98765432 --body "<p>Fixed typo</p>" \ --message "Corrected spelling errors" ``` ### Deleting Pages ```bash # Move to trash (recoverable) confluence.py delete-page 98765432 # Permanently delete (purge) confluence.py delete-page 98765432 --purge ``` ### Page Tree Navigation ```bash # Get child pages confluence.py children 98765432 # Get parent chain (ancestors) confluence.py ancestors 98765432 ``` ### Content Search (CQL) Confluence Query Language (CQL) supports powerful filtering: ```bash # Search by type confluence.py search "type=page" # Search in specific space confluence.py search "type=page AND space=PROJ" # Full-text search confluence.py search "text ~ 'meeting notes'" # Recently modified confluence.py search "type=page AND lastModified > now('-7d')" # By label confluence.py search "type=page AND label=important" # By creator confluence.py search "type=page AND creator=currentUser()" # Combined query confluence.py search "type=page AND space=PROJ AND text ~ 'api' AND lastModified > now('-30d')" ``` ### Attachments ```bash # List attachments on a page confluence.py attachments 98765432 # Filter by filename confluence.py attachments 98765432 --filename "report.pdf" # Filter by media type confluence.py attachments 98765432 --media-type "image/png" # Upload a file confluence.py upload 98765432 /path/to/document.pdf # Upload with comment confluence.py upload 98765432 /path/to/file.pdf --comment "Q3 report" # Download attachment confluence.py download att123456 -o downloaded_file.pdf # Delete attachment (move to trash) confluence.py delete-attachment att123456 # Permanently delete confluence.py delete-attachment att123456 --purge ``` ### Comments ```bash # List comments on a page confluence.py comments 98765432 # Get comment with body confluence.py comment comm123456 --body-format storage # Add a comment confluence.py create-comment 98765432 "<p>Great work!</p>" # Update a comment confluence.py update-comment comm123456 "<p>Updated comment</p>" # Delete a comment confluence.py delete-comment comm123456 ``` ### Labels ```bash # Get labels for a page confluence.py labels 98765432 # Add labels confluence.py add-labels 98765432 "documentation,api,important" # Remove a label confluence.py remove-label 98765432 "draft" ``` ## Storage Format Reference Confluence uses Storage Format (XHTML-based) for page content. See `references/storage-format.md` for details. ### Common Elements ```html <!-- Headings --> <h1>Heading 1</h1> <h2>Heading 2</h2> <!-- Paragraphs --> <p>Regular paragraph</p> <p><strong>Bold</strong> and <em>italic</em> text</p> <!-- Lists --> <ul> <li>Unordered item</li> </ul> <ol> <li>Ordered item</li> </ol> <!-- Links --> <a href="https://example.com">External link</a> <ac:link><ri:page ri:content-title="Other Page" /></ac:link> <!-- Code block --> <ac:structured-macro ac:name="code"> <ac:parameter ac:name="language">python</ac:parameter> <ac:plain-text-body><![CDATA[print("Hello")]]></ac:plain-text-body> </ac:structured-macro> <!-- Info panel --> <ac:structured-macro ac:name="info"> <ac:rich-text-body><p>Info message</p></ac:rich-text-body> </ac:structured-macro> <!-- Table --> <table> <tr><th>Header</th></tr> <tr><td>Cell</td></tr> </table> ``` ## CQL Reference See `references/cql.md` for the full CQL reference. ### Common CQL Patterns | Query | Description | |-------|-------------| | `type=page` | All pages | | `type=blogpost` | All blog posts | | `space=KEY` | Content in space | | `text ~ "keyword"` | Full-text search | | `title ~ "keyword"` | Title search | | `label=labelname` | Has label | | `creator=currentUser()` | Created by me | | `lastModified > now('-7d')` | Modified last week | ## Error Handling Common errors: - **401 Unauthorized**: Check CONFLUENCE_USER_EMAIL and CONFLUENCE_API_TOKEN - **403 Forbidden**: User lacks permission for this operation - **404 Not Found**: Space, page, or content doesn't exist - **400 Bad Request**: Invalid parameters or malformed storage format ## Raw API Access For operations not covered by the script: ```bash # V2 API (preferred) curl -s -u "$CONFLUENCE_USER_EMAIL:$CONFLUENCE_API_TOKEN" \ "$CONFLUENCE_BASE_URL/wiki/api/v2/pages?limit=5" | jq . # V1 API (legacy, some features) curl -s -u "$CONFLUENCE_USER_EMAIL:$CONFLUENCE_API_TOKEN" \ "$CONFLUENCE_BASE_URL/wiki/rest/api/content?type=page&limit=5" | jq . # POST with body curl -s -X POST -u "$CONFLUENCE_USER_EMAIL:$CONFLUENCE_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"spaceId":"12345","title":"Test","body":{"representation":"storage","value":"<p>Hello</p>"}}' \ "$CONFLUENCE_BASE_URL/wiki/api/v2/pages" | jq . ``` API docs: - V2: https://developer.atlassian.com/cloud/confluence/rest/v2/intro/ - V1: https://developer.atlassian.com/cloud/confluence/rest/v1/intro/

path: references/cql.md
content: # Confluence Query Language (CQL) Reference

CQL is a SQL-like query language for searching Confluence content.

## Basic Syntax

```
field operator value [AND|OR field operator value]
```

## Fields

### Content Fields

| Field | Description | Example |
|-------|-------------|---------|
| `type` | Content type | `type=page` |
| `space` | Space key | `space=PROJ` |
| `title` | Page title | `title="Meeting Notes"` |
| `text` | Full-text content | `text ~ "api"` |
| `id` | Content ID | `id=12345678` |

### User Fields

| Field | Description | Example |
|-------|-------------|---------|
| `creator` | Created by user | `creator=currentUser()` |
| `contributor` | Edited by user | `contributor="john.doe"` |
| `mention` | Mentions user | `mention=currentUser()` |
| `watcher` | Watched by user | `watcher=currentUser()` |
| `favourite` | Favorited by user | `favourite=currentUser()` |

### Date Fields

| Field | Description | Example |
|-------|-------------|---------|
| `created` | Creation date | `created > "2024-01-01"` |
| `lastModified` | Last modified | `lastModified > now('-7d')` |

### Label Fields

| Field | Description | Example |
|-------|-------------|---------|
| `label` | Has label | `label=documentation` |

### Parent/Ancestor Fields

| Field | Description | Example |
|-------|-------------|---------|
| `parent` | Direct parent | `parent=12345678` |
| `ancestor` | Any ancestor | `ancestor=12345678` |

## Operators

### Comparison Operators

| Operator | Description | Example |
|----------|-------------|---------|
| `=` | Equals | `type=page` |
| `!=` | Not equals | `type!=blogpost` |
| `>` | Greater than | `created > "2024-01-01"` |
| `>=` | Greater or equal | `lastModified >= "2024-01-01"` |
| `<` | Less than | `created < now()` |
| `<=` | Less or equal | `lastModified <= now()` |

### Text Operators

| Operator | Description | Example |
|----------|-------------|---------|
| `~` | Contains (fuzzy) | `text ~ "keyword"` |
| `!~` | Does not contain | `text !~ "draft"` |

### Logical Operators

| Operator | Description | Example |
|----------|-------------|---------|
| `AND` | Both conditions | `type=page AND space=PROJ` |
| `OR` | Either condition | `label=api OR label=documentation` |
| `NOT` | Negation | `NOT label=archived` |

### List Operators

| Operator | Description | Example |
|----------|-------------|---------|
| `IN` | In list | `space IN (PROJ, DOCS)` |
| `NOT IN` | Not in list | `label NOT IN (draft, wip)` |

## Content Types

| Type | Description |
|------|-------------|
| `page` | Wiki pages |
| `blogpost` | Blog posts |
| `attachment` | File attachments |
| `comment` | Comments |
| `space` | Spaces |

## Functions

### User Functions

| Function | Description |
|----------|-------------|
| `currentUser()` | Currently logged in user |

### Date Functions

| Function | Description | Example |
|----------|-------------|---------|
| `now()` | Current time | `lastModified > now()` |
| `now('offset')` | Time with offset | `now('-7d')` for 7 days ago |
| `startOfDay()` | Start of today | `created >= startOfDay()` |
| `startOfWeek()` | Start of this week | `created >= startOfWeek()` |
| `startOfMonth()` | Start of this month | `created >= startOfMonth()` |
| `startOfYear()` | Start of this year | `created >= startOfYear()` |
| `endOfDay()` | End of today | `created <= endOfDay()` |
| `endOfWeek()` | End of this week | |
| `endOfMonth()` | End of this month | |
| `endOfYear()` | End of this year | |

### Date Offset Format

```
now('±Nd') - Days
now('±Nw') - Weeks
now('±NM') - Months
now('±Ny') - Years
now('±Nh') - Hours
now('±Nm') - Minutes
```

Examples:
- `now('-7d')` - 7 days ago
- `now('-2w')` - 2 weeks ago
- `now('-1M')` - 1 month ago
- `now('+1d')` - 1 day in future

## Example Queries

### By Type

```cql
# All pages
type=page

# All blog posts
type=blogpost

# All attachments
type=attachment
```

### By Space

```cql
# Pages in specific space
type=page AND space=PROJ

# Pages in multiple spaces
type=page AND space IN (PROJ, DOCS, WIKI)

# Exclude space
type=page AND space != ARCHIVE
```

### Full-Text Search

```cql
# Search in content
text ~ "api documentation"

# Search in title
title ~ "meeting"

# Combined
title ~ "release" AND text ~ "features"
```

### By User

```cql
# My pages
type=page AND creator=currentUser()

# Pages I've edited
type=page AND contributor=currentUser()

# Pages mentioning me
type=page AND mention=currentUser()

# My favorites
type=page AND favourite=currentUser()
```

### By Date

```cql
# Created today
type=page AND created >= startOfDay()

# Modified this week
type=page AND lastModified >= startOfWeek()

# Modified in last 7 days
type=page AND lastModified > now('-7d')

# Created in 2024
type=page AND created >= "2024-01-01" AND created < "2025-01-01"

# Modified in last hour
type=page AND lastModified > now('-1h')
```

### By Label

```cql
# Has specific label
type=page AND label=documentation

# Has any of multiple labels
type=page AND (label=api OR label=sdk)

# Multiple labels (AND)
type=page AND label=api AND label=v2

# Exclude label
type=page AND NOT label=draft
```

### By Hierarchy

```cql
# Direct children of page
type=page AND parent=12345678

# All descendants of page
type=page AND ancestor=12345678
```

### Complex Queries

```cql
# Recent API documentation
type=page AND 
space=DOCS AND 
label=api AND 
lastModified > now('-30d')

# Meeting notes from this month
type=page AND 
title ~ "meeting" AND 
created >= startOfMonth() AND
space=TEAM

# Draft pages I created
type=page AND 
creator=currentUser() AND 
label=draft

# Active projects in multiple spaces
type=page AND 
space IN (PROJ1, PROJ2, PROJ3) AND 
label=active AND 
NOT label=archived AND
lastModified > now('-7d')
```

## Sorting

Note: Sorting is not directly supported in CQL. Results are sorted by relevance by default. Use the API's sort parameter instead:

```bash
# Via API parameter
confluence.py search "type=page" --sort "-modified"
```

## Tips

1. **Use quotes for values with spaces**: `title="My Page Title"`
2. **Use `~` for fuzzy/partial matching**: `text ~ "partial"`
3. **Combine conditions efficiently**: Put most restrictive first
4. **Test queries incrementally**: Start simple, add conditions
5. **Use IN for multiple values**: Cleaner than multiple OR
6. **Remember field names are case-sensitive**

## Common Patterns

### Find outdated content
```cql
type=page AND lastModified < now('-90d') AND NOT label=archived
```

### Find my recent work
```cql
type=page AND contributor=currentUser() AND lastModified > now('-7d')
```

### Find all documentation
```cql
type=page AND label IN (documentation, docs, guide, tutorial)
```

### Find empty/stub pages
```cql
type=page AND label=stub OR label=todo OR label=wip
```


path: references/storage-format.md
content: # Confluence Storage Format Reference

Confluence uses an XHTML-based storage format for page content. This document covers the most common elements and macros.

## Basic Elements

### Headings

```html
<h1>Heading 1</h1>
<h2>Heading 2</h2>
<h3>Heading 3</h3>
<h4>Heading 4</h4>
<h5>Heading 5</h5>
<h6>Heading 6</h6>
```

### Paragraphs and Text

```html
<p>A simple paragraph.</p>
<p><strong>Bold text</strong></p>
<p><em>Italic text</em></p>
<p><u>Underlined text</u></p>
<p>
Strikethrough text
</p>
<p><sub>subscript</sub> and <sup>superscript</sup></p>
<p><code>inline code</code></p>
```

### Line Breaks

```html
<p>Line one<br />Line two</p>
```

### Horizontal Rule

```html
<hr />
```

## Lists

### Unordered List

```html
<ul>
  <li>Item 1</li>
  <li>Item 2
    <ul>
      <li>Nested item</li>
    </ul>
  </li>
</ul>
```

### Ordered List

```html
<ol>
  <li>First</li>
  <li>Second</li>
  <li>Third</li>
</ol>
```

### Task List

```html
<ac:task-list>
  <ac:task>
    <ac:task-id>1</ac:task-id>
    <ac:task-status>incomplete</ac:task-status>
    <ac:task-body>Task to complete</ac:task-body>
  </ac:task>
  <ac:task>
    <ac:task-id>2</ac:task-id>
    <ac:task-status>complete</ac:task-status>
    <ac:task-body>Completed task</ac:task-body>
  </ac:task>
</ac:task-list>
```

## Links

### External Link

```html
<a href="https://example.com">Link text</a>
<a href="https://example.com" target="_blank">Opens in new tab</a>
```

### Internal Page Link

```html
<ac:link>
  <ri:page ri:content-title="Page Title" />
</ac:link>

<!-- With link text -->
<ac:link>
  <ri:page ri:content-title="Page Title" />
  <ac:plain-text-link-body><![CDATA[Custom link text]]></ac:plain-text-link-body>
</ac:link>

<!-- Link to page in different space -->
<ac:link>
  <ri:page ri:content-title="Page Title" ri:space-key="SPACEKEY" />
</ac:link>
```

### Anchor Link

```html
<!-- Create anchor -->
<ac:structured-macro ac:name="anchor">
  <ac:parameter ac:name="">section-name</ac:parameter>
</ac:structured-macro>

<!-- Link to anchor on same page -->
<ac:link ac:anchor="section-name">
  <ac:plain-text-link-body><![CDATA[Jump to section]]></ac:plain-text-link-body>
</ac:link>
```

## Tables

### Basic Table

```html
<table>
  <tbody>
    <tr>
      <th>Header 1</th>
      <th>Header 2</th>
    </tr>
    <tr>
      <td>Cell 1</td>
      <td>Cell 2</td>
    </tr>
    <tr>
      <td>Cell 3</td>
      <td>Cell 4</td>
    </tr>
  </tbody>
</table>
```

### Table with Styling

```html
<table class="wrapped">
  <colgroup>
    <col style="width: 100px;" />
    <col style="width: 200px;" />
  </colgroup>
  <tbody>
    <tr>
      <th style="text-align: center;">Centered Header</th>
      <td style="background-color: #f0f0f0;">Shaded cell</td>
    </tr>
  </tbody>
</table>
```

## Images

### Attached Image

```html
<ac:image>
  <ri:attachment ri:filename="image.png" />
</ac:image>

<!-- With size -->
<ac:image ac:width="300">
  <ri:attachment ri:filename="image.png" />
</ac:image>

<!-- With alt text -->
<ac:image ac:alt="Description">
  <ri:attachment ri:filename="image.png" />
</ac:image>
```

### External Image

```html
<ac:image>
  <ri:url ri:value="https://example.com/image.png" />
</ac:image>
```

## Macros

Macros use `ac:structured-macro` with a name attribute.

### Code Block

```html
<ac:structured-macro ac:name="code">
  <ac:parameter ac:name="language">python</ac:parameter>
  <ac:parameter ac:name="title">Example Code</ac:parameter>
  <ac:parameter ac:name="linenumbers">true</ac:parameter>
  <ac:plain-text-body><![CDATA[
def hello():
    print("Hello, World!")
  ]]></ac:plain-text-body>
</ac:structured-macro>
```

Supported languages: `java`, `javascript`, `python`, `ruby`, `bash`, `sql`, `xml`, `json`, `yaml`, `html`, `css`, `cpp`, `csharp`, `go`, `rust`, etc.

### Panel Macros

```html
<!-- Info panel (blue) -->
<ac:structured-macro ac:name="info">
  <ac:parameter ac:name="title">Note</ac:parameter>
  <ac:rich-text-body>
    <p>Information message</p>
  </ac:rich-text-body>
</ac:structured-macro>

<!-- Note panel (yellow) -->
<ac:structured-macro ac:name="note">
  <ac:rich-text-body>
    <p>Warning message</p>
  </ac:rich-text-body>
</ac:structured-macro>

<!-- Warning panel (red) -->
<ac:structured-macro ac:name="warning">
  <ac:rich-text-body>
    <p>Critical warning</p>
  </ac:rich-text-body>
</ac:structured-macro>

<!-- Tip panel (green) -->
<ac:structured-macro ac:name="tip">
  <ac:rich-text-body>
    <p>Helpful tip</p>
  </ac:rich-text-body>
</ac:structured-macro>
```

### Expand (Collapsible Section)

```html
<ac:structured-macro ac:name="expand">
  <ac:parameter ac:name="title">Click to expand</ac:parameter>
  <ac:rich-text-body>
    <p>Hidden content here</p>
  </ac:rich-text-body>
</ac:structured-macro>
```

### Table of Contents

```html
<ac:structured-macro ac:name="toc">
  <ac:parameter ac:name="maxLevel">3</ac:parameter>
</ac:structured-macro>
```

### Status Macro (Lozenges)

```html
<ac:structured-macro ac:name="status">
  <ac:parameter ac:name="title">IN PROGRESS</ac:parameter>
  <ac:parameter ac:name="colour">Yellow</ac:parameter>
</ac:structured-macro>

<ac:structured-macro ac:name="status">
  <ac:parameter ac:name="title">DONE</ac:parameter>
  <ac:parameter ac:name="colour">Green</ac:parameter>
</ac:structured-macro>

<ac:structured-macro ac:name="status">
  <ac:parameter ac:name="title">BLOCKED</ac:parameter>
  <ac:parameter ac:name="colour">Red</ac:parameter>
</ac:structured-macro>
```

Colors: `Grey`, `Red`, `Yellow`, `Green`, `Blue`

### Excerpt

```html
<ac:structured-macro ac:name="excerpt">
  <ac:parameter ac:name="hidden">false</ac:parameter>
  <ac:rich-text-body>
    <p>This text will be used as the page excerpt.</p>
  </ac:rich-text-body>
</ac:structured-macro>
```

### Include Page

```html
<ac:structured-macro ac:name="include">
  <ac:parameter ac:name=""><ri:page ri:content-title="Page to Include" /></ac:parameter>
</ac:structured-macro>
```

### Children Display

```html
<ac:structured-macro ac:name="children">
  <ac:parameter ac:name="depth">2</ac:parameter>
  <ac:parameter ac:name="sort">title</ac:parameter>
</ac:structured-macro>
```

## User Mentions

```html
<ac:link>
  <ri:user ri:account-id="5a1234567890abcdef012345" />
</ac:link>
```

## Emoticons

```html
<ac:emoticon ac:name="smile" />
<ac:emoticon ac:name="sad" />
<ac:emoticon ac:name="cheeky" />
<ac:emoticon ac:name="laugh" />
<ac:emoticon ac:name="wink" />
<ac:emoticon ac:name="thumbs-up" />
<ac:emoticon ac:name="thumbs-down" />
<ac:emoticon ac:name="information" />
<ac:emoticon ac:name="tick" />
<ac:emoticon ac:name="cross" />
<ac:emoticon ac:name="warning" />
```

## Complete Page Example

```html
<h1>Project Overview</h1>

<ac:structured-macro ac:name="toc">
  <ac:parameter ac:name="maxLevel">2</ac:parameter>
</ac:structured-macro>

<h2>Introduction</h2>
<p>This document describes the project goals and timeline.</p>

<ac:structured-macro ac:name="info">
  <ac:parameter ac:name="title">Status</ac:parameter>
  <ac:rich-text-body>
    <p>Current status: <ac:structured-macro ac:name="status">
      <ac:parameter ac:name="title">IN PROGRESS</ac:parameter>
      <ac:parameter ac:name="colour">Yellow</ac:parameter>
    </ac:structured-macro></p>
  </ac:rich-text-body>
</ac:structured-macro>

<h2>Requirements</h2>
<ul>
  <li>Requirement 1</li>
  <li>Requirement 2</li>
</ul>

<h2>Technical Details</h2>
<ac:structured-macro ac:name="code">
  <ac:parameter ac:name="language">json</ac:parameter>
  <ac:plain-text-body><![CDATA[{
  "name": "project",
  "version": "1.0.0"
}]]></ac:plain-text-body>
</ac:structured-macro>

<h2>Related Pages</h2>
<ac:structured-macro ac:name="children">
  <ac:parameter ac:name="depth">1</ac:parameter>
</ac:structured-macro>
```

## Tips

1. **Always escape special characters** in CDATA sections
2. **Use `<br />` for line breaks** within paragraphs
3. **Tables must have `<tbody>`** wrapper
4. **Test complex formatting** with simple pages first
5. **Check macro names** are correctly spelled (case-sensitive)


path: scripts/confluence.py
content: #!/usr/bin/env python3
"""
Confluence Cloud CLI - Interact with Confluence Cloud REST API v2.

Environment variables required:
  CONFLUENCE_BASE_URL - Confluence instance URL (e.g., https://yourcompany.atlassian.net)
  CONFLUENCE_USER_EMAIL - Atlassian account email
  CONFLUENCE_API_TOKEN - API token from https://id.atlassian.com/manage-profile/security/api-tokens
"""

import argparse
import base64
import json
import mimetypes
import os
import sys
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, quote


def get_config():
    """Get Confluence configuration from environment."""
    base_url = os.environ.get("CONFLUENCE_BASE_URL", "").rstrip("/")
    email = os.environ.get("CONFLUENCE_USER_EMAIL", "")
    [REDACTED_SECRET]"CONFLUENCE_API_TOKEN", "")
    
    if not all([base_url, email, token]):
        missing = []
        if not base_url: missing.append("CONFLUENCE_BASE_URL")
        if not email: missing.append("CONFLUENCE_USER_EMAIL")
        if not [REDACTED_SECRET]"CONFLUENCE_API_TOKEN")
        print(f"Error: Missing environment variables: {', '.join(missing)}", file=sys.stderr)
        sys.exit(1)
    
    return base_url, email, token


def make_request(method, endpoint, data=None, params=None, api_version="v2"):
    """Make authenticated request to Confluence API."""
    base_url, email, [REDACTED_SECRET])
    
    if api_version == "v2":
        url = f"{base_url}/wiki/api/v2{endpoint}"
    else:
        url = f"{base_url}/wiki/rest/api{endpoint}"
    
    if params:
        url = f"{url}?{urlencode(params)}"
    
    auth = base64.b64encode(f"{email}:{token}".encode()).decode()
    
    headers = {
        "Authorization": f"Basic {auth}",
        "Accept": "application/json",
    }
    
    body = None
    if data:
        headers["Content-Type"] = "application/json"
        body = json.dumps(data).encode()
    
    req = Request(url, data=body, headers=headers, method=method)
    
    try:
        with urlopen(req) as resp:
            content = resp.read().decode()
            return json.loads(content) if content else {}
    except HTTPError as e:
        error_body = e.read().decode()
        try:
            error_json = json.loads(error_body)
            print(json.dumps(error_json, indent=2), file=sys.stderr)
        except:
            print(f"HTTP {e.code}: {error_body}", file=sys.stderr)
        sys.exit(1)
    except URLError as e:
        print(f"Connection error: {e.reason}", file=sys.stderr)
        sys.exit(1)


def make_multipart_request(endpoint, file_path, comment=None):
    """Make multipart form request for file upload."""
    base_url, email, [REDACTED_SECRET])
    url = f"{base_url}/wiki/rest/api{endpoint}"
    
    auth = base64.b64encode(f"{email}:{token}".encode()).decode()
    
    # Read file
    with open(file_path, "rb") as f:
        file_data = f.read()
    
    filename = os.path.basename(file_path)
    content_type, _ = mimetypes.guess_type(filename)
    if not content_type:
        content_type = "application/octet-stream"
    
    # Build multipart body
    boundary = [REDACTED_SECRET]
    
    body_parts = []
    
    # File part
    body_parts.append(f'--{boundary}'.encode())
    body_parts.append(f'Content-Disposition: form-data; name="file"; filename="{filename}"'.encode())
    body_parts.append(f'Content-Type: {content_type}'.encode())
    body_parts.append(b'')
    body_parts.append(file_data)
    
    # Comment part (optional)
    if comment:
        body_parts.append(f'--{boundary}'.encode())
        body_parts.append(b'Content-Disposition: form-data; name="comment"')
        body_parts.append(b'Content-Type: text/plain; charset=utf-8')
        body_parts.append(b'')
        body_parts.append(comment.encode())
    
    # Minor edit
    body_parts.append(f'--{boundary}'.encode())
    body_parts.append(b'Content-Disposition: form-data; name="minorEdit"')
    body_parts.append(b'')
    body_parts.append(b'true')
    
    body_parts.append(f'--{boundary}--'.encode())
    
    body = b'\r\n'.join(body_parts)
    
    headers = {
        "Authorization": f"Basic {auth}",
        "Accept": "application/json",
        "Content-Type": f"multipart/form-data; boundary={boundary}",
        "X-Atlassian-Token": "nocheck",
    }
    
    req = Request(url, data=body, headers=headers, method="PUT")
    
    try:
        with urlopen(req) as resp:
            content = resp.read().decode()
            return json.loads(content) if content else {}
    except HTTPError as e:
        error_body = e.read().decode()
        try:
            error_json = json.loads(error_body)
            print(json.dumps(error_json, indent=2), file=sys.stderr)
        except:
            print(f"HTTP {e.code}: {error_body}", file=sys.stderr)
        sys.exit(1)


# =============================================================================
# Space Commands
# =============================================================================

def cmd_spaces(args):
    """List all spaces."""
    params = {"limit": args.limit}
    if args.type:
        params["type"] = args.type
    if args.status:
        params["status"] = args.status
    if args.keys:
        params["keys"] = args.keys
    
    result = make_request("GET", "/spaces", params=params)
    
    spaces = []
    for space in result.get("results", []):
        spaces.append({
            "id": space.get("id"),
            "key": space.get("key"),
            "name": space.get("name"),
            "type": space.get("type"),
            "status": space.get("status"),
            "homepageId": space.get("homepageId"),
        })
    
    print(json.dumps(spaces, indent=2))


def cmd_space(args):
    """Get space details by ID."""
    params = {}
    if args.include_labels:
        params["include-labels"] = "true"
    
    result = make_request("GET", f"/spaces/{args.space_id}", params=params if params else None)
    print(json.dumps(result, indent=2))


def cmd_space_by_key(args):
    """Get space by key."""
    params = {"keys": args.space_key, "limit": 1}
    result = make_request("GET", "/spaces", params=params)
    
    spaces = result.get("results", [])
    if not spaces:
        print(f"Error: Space with key '{args.space_key}' not found", file=sys.stderr)
        sys.exit(1)
    
    print(json.dumps(spaces[0], indent=2))


def cmd_create_space(args):
    """Create a new space."""
    data = {
        "name": args.name,
        "key": args.key,
    }
    
    if args.description:
        data["description"] = {
            "value": args.description,
            "representation": "plain"
        }
    
    if args.private:
        data["createPrivateSpace"] = True
    
    result = make_request("POST", "/spaces", data)
    print(json.dumps(result, indent=2))


# =============================================================================
# Page Commands
# =============================================================================

def cmd_pages(args):
    """List pages."""
    params = {"limit": args.limit}
    
    if args.space_id:
        endpoint = f"/spaces/{args.space_id}/pages"
    else:
        endpoint = "/pages"
        if args.title:
            params["title"] = args.title
    
    if args.status:
        params["status"] = args.status
    if args.body_format:
        params["body-format"] = args.body_format
    
    result = make_request("GET", endpoint, params=params)
    
    pages = []
    for page in result.get("results", []):
        pages.append({
            "id": page.get("id"),
            "title": page.get("title"),
            "status": page.get("status"),
            "spaceId": page.get("spaceId"),
            "parentId": page.get("parentId"),
            "createdAt": page.get("createdAt"),
            "version": page.get("version", {}).get("number"),
        })
    
    print(json.dumps(pages, indent=2))


def cmd_page(args):
    """Get page by ID."""
    params = {}
    if args.body_format:
        params["body-format"] = args.body_format
    if args.include_labels:
        params["include-labels"] = "true"
    if args.version:
        params["version"] = args.version
    
    result = make_request("GET", f"/pages/{args.page_id}", params=params if params else None)
    print(json.dumps(result, indent=2))


def cmd_create_page(args):
    """Create a new page."""
    data = {
        "spaceId": args.space_id,
        "title": args.title,
        "body": {
            "representation": "storage",
            "value": args.body or ""
        }
    }
    
    if args.parent_id:
        data["parentId"] = args.parent_id
    
    if args.status:
        data["status"] = args.status
    
    result = make_request("POST", "/pages", data)
    print(json.dumps(result, indent=2))


def cmd_update_page(args):
    """Update an existing page."""
    # First get current page to get version number
    current = make_request("GET", f"/pages/{args.page_id}")
    current_version = current.get("version", {}).get("number", 1)
    
    data = {
        "id": args.page_id,
        "status": args.status or "current",
        "title": args.title or current.get("title"),
        "body": {
            "representation": "storage",
            "value": args.body
        },
        "version": {
            "number": current_version + 1
        }
    }
    
    if args.message:
        data["version"]["message"] = args.message
    
    result = make_request("PUT", f"/pages/{args.page_id}", data)
    print(json.dumps(result, indent=2))


def cmd_delete_page(args):
    """Delete a page."""
    params = {}
    if args.purge:
        params["purge"] = "true"
    
    make_request("DELETE", f"/pages/{args.page_id}", params=params if params else None)
    print(json.dumps({"status": "deleted", "id": args.page_id, "purged": args.purge}))


# =============================================================================
# Page Tree / Children Commands
# =============================================================================

def cmd_children(args):
    """Get child pages."""
    params = {"limit": args.limit}
    
    result = make_request("GET", f"/pages/{args.page_id}/children", params=params)
    
    children = []
    for child in result.get("results", []):
        children.append({
            "id": child.get("id"),
            "title": child.get("title"),
            "status": child.get("status"),
            "spaceId": child.get("spaceId"),
        })
    
    print(json.dumps(children, indent=2))


def cmd_ancestors(args):
    """Get page ancestors (parent chain)."""
    # Use v1 API for ancestors
    result = make_request("GET", f"/content/{args.page_id}?expand=ancestors", api_version="v1")
    
    ancestors = []
    for ancestor in result.get("ancestors", []):
        ancestors.append({
            "id": ancestor.get("id"),
            "title": ancestor.get("title"),
            "type": ancestor.get("type"),
        })
    
    print(json.dumps(ancestors, indent=2))


# =============================================================================
# Search Commands
# =============================================================================

def cmd_search(args):
    """Search content using CQL."""
    params = {
        "cql": args.cql,
        "limit": args.limit,
    }
    
    if args.expand:
        params["expand"] = args.expand
    
    result = make_request("GET", "/search", params=params, api_version="v1")
    
    results = []
    for item in result.get("results", []):
        content = item.get("content", {})
        results.append({
            "id": content.get("id"),
            "type": content.get("type"),
            "title": item.get("title") or content.get("title"),
            "space": content.get("space", {}).get("key"),
            "excerpt": item.get("excerpt"),
            "url": item.get("url"),
            "lastModified": item.get("lastModified"),
        })
    
    print(json.dumps(results, indent=2))


# =============================================================================
# Attachment Commands
# =============================================================================

def cmd_attachments(args):
    """List attachments for a page."""
    params = {"limit": args.limit}
    if args.filename:
        params["filename"] = args.filename
    if args.media_type:
        params["mediaType"] = args.media_type
    
    result = make_request("GET", f"/pages/{args.page_id}/attachments", params=params)
    
    attachments = []
    for att in result.get("results", []):
        attachments.append({
            "id": att.get("id"),
            "title": att.get("title"),
            "mediaType": att.get("mediaType"),
            "fileSize": att.get("fileSize"),
            "downloadLink": att.get("downloadLink"),
            "createdAt": att.get("createdAt"),
        })
    
    print(json.dumps(attachments, indent=2))


def cmd_attachment(args):
    """Get attachment by ID."""
    result = make_request("GET", f"/attachments/{args.attachment_id}")
    print(json.dumps(result, indent=2))


def cmd_upload_attachment(args):
    """Upload an attachment to a page."""
    result = make_multipart_request(
        f"/content/{args.page_id}/child/attachment",
        args.file_path,
        args.comment
    )
    print(json.dumps(result, indent=2))


def cmd_download_attachment(args):
    """Download an attachment."""
    base_url, email, [REDACTED_SECRET])
    
    # Get attachment info
    att = make_request("GET", f"/attachments/{args.attachment_id}")
    download_link = att.get("downloadLink")
    
    if not download_link:
        print("Error: No download link found", file=sys.stderr)
        sys.exit(1)
    
    url = f"{base_url}/wiki{download_link}"
    
    auth = base64.b64encode(f"{email}:{token}".encode()).decode()
    headers = {"Authorization": f"Basic {auth}"}
    
    req = Request(url, headers=headers)
    
    try:
        with urlopen(req) as resp:
            output_path = args.output or att.get("title", "attachment")
            with open(output_path, "wb") as f:
                f.write(resp.read())
            print(json.dumps({"status": "downloaded", "path": output_path}))
    except HTTPError as e:
        print(f"HTTP {e.code}: Failed to download", file=sys.stderr)
        sys.exit(1)


def cmd_delete_attachment(args):
    """Delete an attachment."""
    params = {}
    if args.purge:
        params["purge"] = "true"
    
    make_request("DELETE", f"/attachments/{args.attachment_id}", params=params if params else None)
    print(json.dumps({"status": "deleted", "id": args.attachment_id}))


# =============================================================================
# Comment Commands
# =============================================================================

def cmd_comments(args):
    """List comments for a page."""
    params = {"limit": args.limit}
    if args.body_format:
        params["body-format"] = args.body_format
    
    result = make_request("GET", f"/pages/{args.page_id}/footer-comments", params=params)
    
    comments = []
    for comment in result.get("results", []):
        body = comment.get("body", {})
        body_value = ""
        if body.get("storage"):
            body_value = body.get("storage", {}).get("value", "")
        elif body.get("atlas_doc_format"):
            body_value = body.get("atlas_doc_format", {}).get("value", "")
        
        comments.append({
            "id": comment.get("id"),
            "status": comment.get("status"),
            "body": body_value,
            "version": comment.get("version", {}).get("number"),
        })
    
    print(json.dumps(comments, indent=2))


def cmd_comment(args):
    """Get comment by ID."""
    params = {}
    if args.body_format:
        params["body-format"] = args.body_format
    
    result = make_request("GET", f"/footer-comments/{args.comment_id}", params=params if params else None)
    print(json.dumps(result, indent=2))


def cmd_create_comment(args):
    """Create a comment on a page."""
    data = {
        "pageId": args.page_id,
        "body": {
            "representation": "storage",
            "value": args.body
        }
    }
    
    result = make_request("POST", "/footer-comments", data)
    print(json.dumps(result, indent=2))


def cmd_update_comment(args):
    """Update a comment."""
    # Get current version
    current = make_request("GET", f"/footer-comments/{args.comment_id}")
    current_version = current.get("version", {}).get("number", 1)
    
    data = {
        "body": {
            "representation": "storage",
            "value": args.body
        },
        "version": {
            "number": current_version + 1
        }
    }
    
    result = make_request("PUT", f"/footer-comments/{args.comment_id}", data)
    print(json.dumps(result, indent=2))


def cmd_delete_comment(args):
    """Delete a comment."""
    make_request("DELETE", f"/footer-comments/{args.comment_id}")
    print(json.dumps({"status": "deleted", "id": args.comment_id}))


# =============================================================================
# Label Commands
# =============================================================================

def cmd_labels(args):
    """Get labels for a page."""
    params = {"limit": args.limit}
    
    result = make_request("GET", f"/pages/{args.page_id}/labels", params=params)
    
    labels = []
    for label in result.get("results", []):
        labels.append({
            "id": label.get("id"),
            "name": label.get("name"),
            "prefix": label.get("prefix"),
        })
    
    print(json.dumps(labels, indent=2))


def cmd_add_labels(args):
    """Add labels to a page."""
    labels = [{"prefix": "global", "name": l.strip()} for l in args.labels.split(",")]
    
    # V1 API for label management
    result = make_request("POST", f"/content/{args.page_id}/label", labels, api_version="v1")
    print(json.dumps(result, indent=2))


def cmd_remove_label(args):
    """Remove a label from a page."""
    make_request("DELETE", f"/content/{args.page_id}/label/{args.label}", api_version="v1")
    print(json.dumps({"status": "removed", "label": args.label}))


# =============================================================================
# User Commands
# =============================================================================

def cmd_me(args):
    """Get current user info."""
    result = make_request("GET", "/user/current", api_version="v1")
    print(json.dumps(result, indent=2))


# =============================================================================
# Main
# =============================================================================

def main():
    parser = argparse.ArgumentParser(description="Confluence Cloud CLI")
    subparsers = parser.add_subparsers(dest="command", help="Commands")
    
    # --- Spaces ---
    p = subparsers.add_parser("spaces", help="List spaces")
    p.add_argument("--type", choices=["global", "personal"])
    p.add_argument("--status", choices=["current", "archived"])
    p.add_argument("--keys", help="Comma-separated space keys")
    p.add_argument("--limit", type=int, default=25)
    p.set_defaults(func=cmd_spaces)
    
    p = subparsers.add_parser("space", help="Get space by ID")
    p.add_argument("space_id", help="Space ID")
    p.add_argument("--include-labels", action="store_true")
    p.set_defaults(func=cmd_space)
    
    p = subparsers.add_parser("space-by-key", help="Get space by key")
    p.add_argument("space_key", help="Space key (e.g., PROJ)")
    p.set_defaults(func=cmd_space_by_key)
    
    p = subparsers.add_parser("create-space", help="Create space")
    p.add_argument("--name", required=True, help="Space name")
    p.add_argument("--key", required=True, help="Space key")
    p.add_argument("--description", help="Space description")
    p.add_argument("--private", action="store_true", help="Create private space")
    p.set_defaults(func=cmd_create_space)
    
    # --- Pages ---
    p = subparsers.add_parser("pages", help="List pages")
    p.add_argument("--space-id", help="Filter by space ID")
    p.add_argument("--title", help="Filter by title")
    p.add_argument("--status", choices=["current", "draft", "trashed"])
    p.add_argument("--body-format", choices=["storage", "atlas_doc_format", "view"])
    p.add_argument("--limit", type=int, default=25)
    p.set_defaults(func=cmd_pages)
    
    p = subparsers.add_parser("page", help="Get page by ID")
    p.add_argument("page_id", help="Page ID")
    p.add_argument("--body-format", choices=["storage", "atlas_doc_format", "view"])
    p.add_argument("--include-labels", action="store_true")
    p.add_argument("--version", type=int, help="Specific version")
    p.set_defaults(func=cmd_page)
    
    p = subparsers.add_parser("create-page", help="Create page")
    p.add_argument("--space-id", required=True, help="Space ID")
    p.add_argument("--title", required=True, help="Page title")
    p.add_argument("--body", help="Page body (storage format)")
    p.add_argument("--parent-id", help="Parent page ID")
    p.add_argument("--status", choices=["current", "draft"], default="current")
    p.set_defaults(func=cmd_create_page)
    
    p = subparsers.add_parser("update-page", help="Update page")
    p.add_argument("page_id", help="Page ID")
    p.add_argument("--title", help="New title")
    p.add_argument("--body", required=True, help="New body (storage format)")
    p.add_argument("--status", choices=["current", "draft"])
    p.add_argument("--message", help="Version message")
    p.set_defaults(func=cmd_update_page)
    
    p = subparsers.add_parser("delete-page", help="Delete page")
    p.add_argument("page_id", help="Page ID")
    p.add_argument("--purge", action="store_true", help="Permanently delete")
    p.set_defaults(func=cmd_delete_page)
    
    # --- Page Tree ---
    p = subparsers.add_parser("children", help="Get child pages")
    p.add_argument("page_id", help="Parent page ID")
    p.add_argument("--limit", type=int, default=25)
    p.set_defaults(func=cmd_children)
    
    p = subparsers.add_parser("ancestors", help="Get page ancestors")
    p.add_argument("page_id", help="Page ID")
    p.set_defaults(func=cmd_ancestors)
    
    # --- Search ---
    p = subparsers.add_parser("search", help="Search content (CQL)")
    p.add_argument("cql", help="CQL query string")
    p.add_argument("--limit", type=int, default=25)
    p.add_argument("--expand", help="Fields to expand")
    p.set_defaults(func=cmd_search)
    
    # --- Attachments ---
    p = subparsers.add_parser("attachments", help="List page attachments")
    p.add_argument("page_id", help="Page ID")
    p.add_argument("--filename", help="Filter by filename")
    p.add_argument("--media-type", help="Filter by media type")
    p.add_argument("--limit", type=int, default=25)
    p.set_defaults(func=cmd_attachments)
    
    p = subparsers.add_parser("attachment", help="Get attachment by ID")
    p.add_argument("attachment_id", help="Attachment ID")
    p.set_defaults(func=cmd_attachment)
    
    p = subparsers.add_parser("upload", help="Upload attachment")
    p.add_argument("page_id", help="Page ID")
    p.add_argument("file_path", help="Path to file")
    p.add_argument("--comment", help="Attachment comment")
    p.set_defaults(func=cmd_upload_attachment)
    
    p = subparsers.add_parser("download", help="Download attachment")
    p.add_argument("attachment_id", help="Attachment ID")
    p.add_argument("--output", "-o", help="Output file path")
    p.set_defaults(func=cmd_download_attachment)
    
    p = subparsers.add_parser("delete-attachment", help="Delete attachment")
    p.add_argument("attachment_id", help="Attachment ID")
    p.add_argument("--purge", action="store_true", help="Permanently delete")
    p.set_defaults(func=cmd_delete_attachment)
    
    # --- Comments ---
    p = subparsers.add_parser("comments", help="List page comments")
    p.add_argument("page_id", help="Page ID")
    p.add_argument("--body-format", choices=["storage", "atlas_doc_format", "view"])
    p.add_argument("--limit", type=int, default=25)
    p.set_defaults(func=cmd_comments)
    
    p = subparsers.add_parser("comment", help="Get comment by ID")
    p.add_argument("comment_id", help="Comment ID")
    p.add_argument("--body-format", choices=["storage", "atlas_doc_format", "view"])
    p.set_defaults(func=cmd_comment)
    
    p = subparsers.add_parser("create-comment", help="Create comment")
    p.add_argument("page_id", help="Page ID")
    p.add_argument("body", help="Comment body (storage format)")
    p.set_defaults(func=cmd_create_comment)
    
    p = subparsers.add_parser("update-comment", help="Update comment")
    p.add_argument("comment_id", help="Comment ID")
    p.add_argument("body", help="New body (storage format)")
    p.set_defaults(func=cmd_update_comment)
    
    p = subparsers.add_parser("delete-comment", help="Delete comment")
    p.add_argument("comment_id", help="Comment ID")
    p.set_defaults(func=cmd_delete_comment)
    
    # --- Labels ---
    p = subparsers.add_parser("labels", help="Get page labels")
    p.add_argument("page_id", help="Page ID")
    p.add_argument("--limit", type=int, default=50)
    p.set_defaults(func=cmd_labels)
    
    p = subparsers.add_parser("add-labels", help="Add labels to page")
    p.add_argument("page_id", help="Page ID")
    p.add_argument("labels", help="Comma-separated label names")
    p.set_defaults(func=cmd_add_labels)
    
    p = subparsers.add_parser("remove-label", help="Remove label from page")
    p.add_argument("page_id", help="Page ID")
    p.add_argument("label", help="Label name")
    p.set_defaults(func=cmd_remove_label)
    
    # --- User ---
    p = subparsers.add_parser("me", help="Get current user")
    p.set_defaults(func=cmd_me)
    
    args = parser.parse_args()
    
    if not args.command:
        parser.print_help()
        sys.exit(1)
    
    args.func(args)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)