Skill: hopyky/wikijs (version 1.4.0)

# Wiki.js Skill v1.4 A complete CLI for managing Wiki.js via the GraphQL API. ## Quick Start ```bash # Install npm install && npm link # Configure cp config/wikijs.example.json ~/.config/wikijs.json # Edit with your Wiki.js URL and API token # Test connection wikijs health ``` ## Commands Reference ### Reading | Command | Description | |---------|-------------| | `wikijs list` | List all pages | | `wikijs search "query"` | Search pages | | `wikijs get <id-or-path>` | Read a page | | `wikijs info <id-or-path>` | Show page metadata | | `wikijs grep "pattern"` | Search within content | | `wikijs tree` | Display page hierarchy | ### Writing | Command | Description | |---------|-------------| | `wikijs create <path> <title>` | Create a page | | `wikijs create ... --template doc` | Create from template | | `wikijs update <id>` | Update a page | | `wikijs move <id> <new-path>` | Move a page | | `wikijs delete <id>` | Delete a page | ### Tags | Command | Description | |---------|-------------| | `wikijs tags` | List all tags | | `wikijs tag <id> add <tag>` | Add a tag | | `wikijs tag <id> remove <tag>` | Remove a tag | ### Backup & Restore | Command | Description | |---------|-------------| | `wikijs backup` | Create backup | | `wikijs restore-backup <file>` | Restore from backup | | `wikijs export <dir>` | Export to files | ### Versions | Command | Description | |---------|-------------| | `wikijs versions <id>` | Show history | | `wikijs revert <id> <version>` | Restore version | | `wikijs diff <id>` | Compare versions | ### Assets | Command | Description | |---------|-------------| | `wikijs images` | List assets | | `wikijs upload <file>` | Upload asset | | `wikijs delete-image <id>` | Delete asset | ### Bulk Operations | Command | Description | |---------|-------------| | `wikijs bulk-create <folder>` | Create from files | | `wikijs bulk-update <folder>` | Update from files | | `wikijs sync` | Sync to local | | `wikijs sync --watch` | Watch mode | ### Analysis | Command | Description | |---------|-------------| | `wikijs tree` | Page hierarchy tree | | `wikijs check-links` | Find broken links | | `wikijs stats` | Show statistics | | `wikijs lint <file>` | Lint markdown file | | `wikijs lint --id <id>` | Lint wiki page | | `wikijs orphans` | Find pages with no incoming links | | `wikijs duplicates` | Find similar/duplicate content | | `wikijs toc <id>` | Generate table of contents | | `wikijs validate <id>` | Validate page content | | `wikijs validate --all` | Validate all pages | | `wikijs spellcheck <id>` | Check spelling | ### Content Operations | Command | Description | |---------|-------------| | `wikijs clone <id> <path>` | Duplicate a page | | `wikijs replace "old" "new"` | Search/replace across pages | | `wikijs sitemap` | Generate XML sitemap | ### Interactive | Command | Description | |---------|-------------| | `wikijs shell` | Interactive shell mode | | `wikijs watch <id>` | Watch page for changes | ### Templates | Command | Description | |---------|-------------| | `wikijs template list` | List templates | | `wikijs template show <name>` | Show template | | `wikijs template create <name>` | Create template | | `wikijs template delete <name>` | Delete template | ### System | Command | Description | |---------|-------------| | `wikijs health` | Check connection | | `wikijs cache clear` | Clear cache | | `wikijs completion bash` | Shell completion | ## Global Options | Option | Description | |--------|-------------| | `-v, --verbose` | Verbose output | | `-d, --debug` | Debug output | | `--no-color` | Disable colors | | `--rate-limit <ms>` | API rate limiting | ## Common Options | Option | Description | |--------|-------------| | `--format json\|table` | Output format | | `--limit <n>` | Limit results | | `--force` | Skip confirmations | | `--locale <locale>` | Specify locale | | `--dry-run` | Preview changes | ## Examples ```bash # Create page with template wikijs template create doc --content "# {{title}}\n\n{{date}}" wikijs create "/docs/api" "API Docs" --template doc # Find broken links in docs section wikijs check-links --path "/docs" # Bulk import with rate limiting wikijs --rate-limit 500 bulk-create ./pages --path-prefix "/imported" # Watch mode for continuous sync wikijs sync --output ~/wiki-mirror --watch --interval 60 # Debug API issues wikijs --debug list # Clone a page wikijs clone 42 "/docs/new-page" --with-tags # Find orphan pages (no incoming links) wikijs orphans # Search and replace across wiki wikijs replace "oldterm" "newterm" --path "/docs" --dry-run # Generate table of contents wikijs toc 42 --format markdown # Find duplicate content wikijs duplicates --threshold 80 # Generate sitemap for SEO wikijs sitemap --output sitemap.xml # Interactive shell mode wikijs shell # Watch a page for changes wikijs watch "/docs/api" --interval 60 # Spell check a page wikijs spellcheck 42 --lang en --ignore "API,CLI,GraphQL" # Validate all pages wikijs validate --all --format json ``` ## Integration Notes - All commands return exit code 0 on success, 1 on failure - Use `--format json` for machine-readable output - Delete operations prompt for confirmation unless `--force` is used - Escape sequences (`\n`, `\t`) are interpreted in `--content` strings - Templates support placeholders: `{{title}}`, `{{path}}`, `{{date}}`

path: CHANGELOG.md
content: # Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.4.0] - 2026-02-04

### Added
- `clone` command - Duplicate a page to a new location with `--with-tags` option
- `orphans` command - Find pages with no incoming links
- `replace` command - Search and replace text across multiple pages
- `toc` command - Generate table of contents for a page (markdown/json/html)
- `duplicates` command - Find pages with similar content
- `sitemap` command - Generate XML sitemap for SEO
- `shell` command - Interactive shell mode (REPL)
- `watch` command - Watch a page for changes with configurable interval
- `spellcheck` command - Basic spell checking with multilingual support (en/fr)
- `validate` command - Comprehensive page validation (images, links, content quality)
- Enhanced `stats` command with word count and reading time metrics

### Changed
- Improved shell completion with all new commands

## [1.3.0] - 2026-02-04

### Added
- `lint` command - Lint markdown files or wiki pages with `--fix` option
- `--lint` flag for `create` command to validate markdown before creating
- Offline mode support for `list` command:
  - `--offline` flag to use cached data when server is unavailable
  - `--save-offline` flag to cache page list for offline use
  - Automatic fallback to cached data on connection failure
- Unit tests with Jest
- Shell completion now includes all commands

### Changed
- Improved shell completion scripts with all available commands
- Test script now uses Jest instead of health check

### Fixed
- Various edge cases in markdown linting
- Better handling of offline scenarios

## [1.2.0] - 2026-02-04

### Added
- `tree` command - Display page hierarchy as a visual tree
- `diff` command - Compare page versions
- `check-links` command - Find broken internal links across all pages
- `template` command - Manage page templates (list, show, create, delete)
- `completion` command - Generate shell completion scripts (bash/zsh/fish)
- `cache` command - Manage local cache (clear, info)
- `--template` option for `create` command
- Global options: `--verbose`, `--debug`, `--no-color`, `--rate-limit`
- Progress bars for bulk operations
- Rate limiting support for API calls
- Local caching system for improved performance
- Input sanitization for GraphQL queries (security)

### Changed
- Bulk operations now show progress bars
- API calls can be rate-limited with `--rate-limit <ms>`
- Debug output available with `--debug` flag
- Colors can be disabled with `--no-color` for CI/scripts

### Security
- Added `sanitizeString()` to prevent GraphQL injection
- Added `validateId()` and `validatePath()` for input validation

## [1.1.0] - 2026-02-03

### Added
- `restore-backup` command to restore pages from backup files
- `bulk-update` command to update multiple pages from local files
- `revert` command to restore a page to a previous version
- `sync --watch` mode for periodic synchronization
- Interactive confirmation prompts for delete operations (pages and assets)
- `--locale` option for `get` command to specify locale for path lookups
- Escape sequence interpretation (`\n`, `\t`, `\r`) in `--content` flag

### Changed
- Improved GraphQL error handling with detailed error messages
- Delete commands now prompt for confirmation (use `--force` to skip)
- Updated documentation to English

### Fixed
- GraphQL errors now show actual error message instead of "status 400"

## [1.0.0] - 2026-02-03

### Added
- Initial release
- Core commands: `list`, `search`, `get`, `create`, `update`, `move`, `delete`
- Tag management: `tags`, `tag add/remove/set`
- Asset management: `images`, `upload`, `delete-image`
- Export/backup: `export`, `backup`
- Search: `grep` for content search across pages
- System: `health`, `stats`, `info`, `versions`
- Bulk operations: `bulk-create`, `sync`
- Support for Wiki.js 2.x GraphQL API
- JSON and table output formats
- Configuration via `~/.config/wikijs.json`


path: README.md
content: # wikijs-cli

A comprehensive command-line interface for [Wiki.js](https://js.wiki/) - the powerful open-source wiki software.

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Node.js Version](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen)](https://nodejs.org/)

## Features

- **Full CRUD operations** - Create, read, update, and delete wiki pages
- **Tag management** - Add, remove, and list tags
- **Asset management** - Upload, list, and delete images/files
- **Search** - Full-text search and content grep
- **Backup & Restore** - Export and import wiki content
- **Bulk operations** - Create/update multiple pages from files with progress bars
- **Version control** - View history and revert to previous versions
- **Watch mode** - Automatic periodic synchronization and page monitoring
- **Tree view** - Visual hierarchy of all pages
- **Link checker** - Find broken internal links
- **Orphan detection** - Find pages with no incoming links
- **Duplicate detection** - Find similar content across pages
- **Templates** - Create pages from reusable templates
- **Shell completion** - Auto-completion for bash/zsh/fish
- **Interactive shell** - REPL mode for multiple commands
- **Markdown linting** - Validate and fix markdown issues
- **Spell checking** - Basic spell check with multilingual support
- **Page validation** - Check images, links, and content quality
- **Search & replace** - Find and replace text across pages
- **Sitemap generation** - Generate XML sitemaps for SEO
- **Offline mode** - Work with cached data when server unavailable
- **Rate limiting** - Configurable delays for bulk operations
- **Debug mode** - Verbose output for troubleshooting

## Installation

```bash
# Clone the repository
git clone https://github.com/your-username/wikijs-cli.git
cd wikijs-cli

# Install dependencies
npm install

# Link globally (optional, for system-wide access)
npm link
```

## Configuration

1. Copy the example configuration:
```bash
cp config/wikijs.example.json ~/.config/wikijs.json
```

2. Edit `~/.config/wikijs.json` with your Wiki.js details:
```json
{
  "url": "http://your-wiki-server:3000",
  "apiToken": "YOUR_API_TOKEN_HERE",
  "defaultEditor": "markdown",
  "defaultLocale": "en"
}
```

### Getting an API Token

1. Log in to your Wiki.js instance as an administrator
2. Go to **Administration** > **API Access**
3. Create a new API key with the required permissions
4. Copy the token to your configuration file

## Usage

### Basic Commands

```bash
# Check connection
wikijs health

# List pages
wikijs list
wikijs list --limit 20 --locale en

# Search pages
wikijs search "documentation"

# Get a page
wikijs get 1                    # By ID
wikijs get "/docs/guide"        # By path
wikijs get 1 --raw --metadata   # With metadata header
```

### Creating & Editing Pages

```bash
# Create a page with inline content
wikijs create "/docs/intro" "Introduction" --content "# Welcome\n\nThis is the intro."

# Create from a file
wikijs create "/docs/guide" "User Guide" --file ./guide.md --tag "docs,guide"

# Create from stdin
cat document.md | wikijs create "/docs/readme" "README" --stdin

# Update a page
wikijs update 1 --content "New content here"
wikijs update "/docs/guide" --file ./updated-guide.md
wikijs update 1 --title "New Title" --add-tags "important"

# Move a page
wikijs move 1 "/new/path"

# Delete a page (with confirmation)
wikijs delete 1
wikijs delete 1 --force  # Skip confirmation
```

### Tag Management

```bash
# List all tags
wikijs tags
wikijs tags --format json

# Manage page tags
wikijs tag 1 add "important"
wikijs tag 1 remove "draft"
wikijs tag 1 set "tag1,tag2,tag3"  # Replace all tags
```

### Search & Discovery

```bash
# Full-text search
wikijs search "configuration"

# Search within page content (grep)
wikijs grep "TODO"
wikijs grep "deprecated" --path "/docs" --case-sensitive

# Get page info
wikijs info 1

# View statistics
wikijs stats
wikijs stats --detailed
```

### Backup & Restore

```bash
# Create a backup
wikijs backup
wikijs backup --output my-backup.json --pages-only

# Export pages to files
wikijs export ./backup --format markdown
wikijs export ./backup --format json --with-assets

# Restore from backup
wikijs restore-backup backup.json --dry-run  # Preview
wikijs restore-backup backup.json --skip-existing
wikijs restore-backup backup.json --force    # Overwrite existing
```

### Version Control

```bash
# View page history
wikijs versions 1

# Revert to a previous version
wikijs revert 1 5  # Revert page 1 to version 5
```

### Bulk Operations

```bash
# Create pages from a folder of markdown files
wikijs bulk-create ./pages --path-prefix "/docs" --tag "imported"
wikijs bulk-create ./pages --dry-run  # Preview

# Update existing pages from files
wikijs bulk-update ./pages --path-prefix "/docs"

# Sync all pages to local directory
wikijs sync --output ./local-wiki
wikijs sync --output ./local-wiki --format json

# Watch mode (sync periodically)
wikijs sync --output ./local-wiki --watch --interval 300
```

### Asset Management

```bash
# List assets
wikijs images
wikijs images --folder "/uploads" --limit 100

# Upload a file
wikijs upload ./image.png
wikijs upload ./doc.pdf --folder "/documents" --rename "manual.pdf"

# Delete an asset
wikijs delete-image 42
```

### Tree View & Analysis

```bash
# Display page hierarchy as a tree
wikijs tree
wikijs tree --locale en

# Find broken internal links
wikijs check-links
wikijs check-links --path "/docs"

# Compare page versions
wikijs diff 1

# Find orphan pages (no incoming links)
wikijs orphans

# Find similar/duplicate content
wikijs duplicates --threshold 70

# Generate table of contents
wikijs toc 1 --format markdown
```

### Page Operations

```bash
# Clone/duplicate a page
wikijs clone 1 "/docs/copy-of-page" --with-tags

# Search and replace across pages
wikijs replace "old-term" "new-term" --path "/docs" --dry-run
wikijs replace "oldAPI" "newAPI" --regex --case-sensitive

# Generate XML sitemap
wikijs sitemap --output sitemap.xml --base-url "https://wiki.example.com"
```

### Content Quality

```bash
# Lint markdown files
wikijs lint ./document.md
wikijs lint ./document.md --fix

# Lint a wiki page
wikijs lint --id 1

# Spell check a page
wikijs spellcheck 1 --lang en
wikijs spellcheck 1 --lang fr --ignore "API,CLI"

# Validate page content (images, links, quality)
wikijs validate 1
wikijs validate --all --format json
```

### Interactive & Watch

```bash
# Interactive shell mode
wikijs shell

# Watch a page for changes
wikijs watch 1 --interval 30
wikijs watch "/docs/important" --interval 60
```

### Offline Mode

```bash
# Save pages for offline use
wikijs list --save-offline

# Use cached data (offline mode)
wikijs list --offline

# Automatic fallback on connection failure
wikijs list  # Will use cache if server unavailable
```

### Templates

```bash
# List available templates
wikijs template list

# Create a template
wikijs template create doc --content "# {{title}}\n\nCreated: {{date}}"

# Use a template when creating a page
wikijs create "/docs/new-page" "New Page" --template doc

# Show/delete templates
wikijs template show doc
wikijs template delete doc
```

### Shell Completion

```bash
# Generate completion script
wikijs completion bash >> ~/.bashrc
wikijs completion zsh >> ~/.zshrc
wikijs completion fish > ~/.config/fish/completions/wikijs.fish
```

## Global Options

```bash
wikijs --verbose <command>     # Verbose output
wikijs --debug <command>       # Debug output (includes verbose)
wikijs --no-color <command>    # Disable colors (for CI/scripts)
wikijs --rate-limit 500 <cmd>  # Add 500ms delay between API calls
```

## Output Formats

Most commands support multiple output formats:

```bash
wikijs list --format table  # Default, human-readable
wikijs list --format json   # Machine-readable JSON
```

## Configuration Options

| Option | Description | Default |
|--------|-------------|---------|
| `url` | Wiki.js server URL | Required |
| `apiToken` | API authentication token | Required |
| `defaultEditor` | Default editor type | `markdown` |
| `defaultLocale` | Default page locale | `en` |
| `autoSync.enabled` | Enable auto-sync | `false` |
| `autoSync.path` | Local sync directory | - |
| `autoSync.intervalHours` | Sync interval | `24` |
| `backup.enabled` | Enable backups | `true` |
| `backup.path` | Backup directory | - |
| `backup.keepDays` | Backup retention | `30` |

## Error Handling

The CLI provides detailed error messages for common issues:

```bash
# Connection errors
✗ Connection failed: ECONNREFUSED
  Config path: ~/.config/wikijs.json

# GraphQL errors (detailed)
✗ Field "nonexistent" not found in type "Page"

# Page not found
✗ Page not found: /invalid/path
```

## Troubleshooting

### Connection Refused
- Verify the `url` in your config file is correct
- Ensure Wiki.js is running and accessible
- Check firewall settings

### Authentication Failed
- Regenerate your API token in Wiki.js admin
- Ensure the token has the required permissions
- Check the token hasn't expired

### Page Not Found (by path)
- Paths are case-sensitive
- Use `wikijs list` to see existing paths
- Try specifying `--locale` if using multiple languages

## API Compatibility

This CLI is compatible with **Wiki.js 2.x** using the GraphQL API.

## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request

## License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

## Acknowledgments

- [Wiki.js](https://js.wiki/) - The amazing wiki platform
- [Commander.js](https://github.com/tj/commander.js/) - CLI framework
- [Ora](https://github.com/sindresorhus/ora) - Elegant terminal spinners
- [Chalk](https://github.com/chalk/chalk) - Terminal string styling


path: config/wikijs.example.json
content: {
  "url": "http://localhost:3000",
  "apiToken": "YOUR_API_TOKEN_HERE",
  "defaultEditor": "markdown",
  "defaultLocale": "en",
  "autoSync": {
    "enabled": false,
    "path": "./sync",
    "intervalHours": 24
  },
  "backup": {
    "enabled": true,
    "path": "./backups",
    "keepDays": 30
  }
}


path: lib/api.js
content: import axios from 'axios';
import FormData from 'form-data';
import { createReadStream, statSync } from 'fs';
import { basename } from 'path';
import { loadConfig } from './config.js';

let client = null;

// Custom error class for GraphQL errors
class GraphQLError extends Error {
  constructor(errors) {
    const messages = errors.map(e => e.message).join('; ');
    super(messages);
    this.name = 'GraphQLError';
    this.errors = errors;
  }
}

// Sanitize string for safe GraphQL interpolation
// Escapes quotes and backslashes to prevent injection
function sanitizeString(str) {
  if (str === null || str === undefined) return '';
  return String(str)
    .replace(/\\/g, '\\\\')
    .replace(/"/g, '\\"')
    .replace(/\n/g, '\\n')
    .replace(/\r/g, '\\r')
    .replace(/\t/g, '\\t');
}

// Validate ID is a positive integer
function validateId(id) {
  const num = parseInt(id, 10);
  if (isNaN(num) || num < 1) {
    throw new Error(`Invalid ID: ${id}`);
  }
  return num;
}

// Validate path format
function validatePath(path) {
  if (!path || typeof path !== 'string') {
    throw new Error('Path is required');
  }
  // Remove leading slash if present for consistency
  const cleanPath = path.startsWith('/') ? path.slice(1) : path;
  // Check for invalid characters
  if (/[<>:"|?*]/.test(cleanPath)) {
    throw new Error(`Invalid characters in path: ${path}`);
  }
  return cleanPath;
}

// Helper to execute GraphQL and handle errors
async function graphql(query) {
  const response = await getClient().post('/graphql', { query });

  // Check for GraphQL errors
  if (response.data?.errors && response.data.errors.length > 0) {
    throw new GraphQLError(response.data.errors);
  }

  return response.data?.data;
}

function getClient() {
  if (client) return client;

  const config = loadConfig();
  client = axios.create({
    baseURL: config.url,
    headers: {
      'Authorization': `Bearer ${config.apiToken}`,
      'Content-Type': 'application/json'
    },
    timeout: 30000
  });

  // Interceptor to provide better error messages
  client.interceptors.response.use(
    response => response,
    error => {
      if (error.response?.data?.errors) {
        throw new GraphQLError(error.response.data.errors);
      }
      throw error;
    }
  );

  return client;
}

// ============ PAGES ============

export async function listPages(options = {}) {
  const { tag, author, locale, limit = 50 } = options;

  // Wiki.js 2.x GraphQL - list doesn't accept parameters
  const query = `
    query {
      pages {
        list {
          id
          path
          title
          description
          locale
          createdAt
          updatedAt
          tags
          isPublished
        }
      }
    }
  `;

  const data = await graphql(query);
  let pages = data?.pages?.list || [];

  // Client-side filtering
  if (locale) {
    pages = pages.filter(p => p.locale === locale);
  }
  if (tag) {
    pages = pages.filter(p => p.tags && p.tags.includes(tag));
  }
  if (author) {
    // Note: authorName not available in list query, would need to fetch each page
    pages = pages.filter(p => p.authorName && p.authorName.toLowerCase().includes(author.toLowerCase()));
  }

  // Apply limit
  return pages.slice(0, limit);
}

export async function searchPages(searchQuery, options = {}) {
  const { limit = 50 } = options;

  const query = `
    query {
      pages {
        search(query: "${sanitizeString(searchQuery)}") {
          results {
            id
            path
            title
            description
            locale
          }
          suggestions
          totalHits
        }
      }
    }
  `;

  const data = await graphql(query);
  const result = data?.pages?.search || { results: [], totalHits: 0 };

  return {
    results: result.results.slice(0, limit),
    totalHits: result.totalHits,
    suggestions: result.suggestions
  };
}

export async function getPage(idOrPath, options = {}) {
  const config = loadConfig();
  const { withChildren = false, locale = config.defaultLocale || 'en' } = options;

  let page;

  if (typeof idOrPath === 'number') {
    // Get by ID
    const validId = validateId(idOrPath);
    const query = `
      query {
        pages {
          single(id: ${validId}) {
            id
            path
            title
            description
            content
            render
            locale
            createdAt
            updatedAt
            authorName
            tags { tag }
            isPublished
            isPrivate
          }
        }
      }
    `;
    const data = await graphql(query);
    page = data?.pages?.single;
    // Flatten tags
    if (page && page.tags) {
      page.tags = page.tags.map(t => t.tag);
    }
  } else {
    // Get by path with configurable locale
    const safePath = sanitizeString(idOrPath);
    const safeLocale = sanitizeString(locale);
    const query = `
      query {
        pages {
          singleByPath(path: "${safePath}", locale: "${safeLocale}") {
            id
            path
            title
            description
            content
            render
            locale
            createdAt
            updatedAt
            authorName
            tags { tag }
            isPublished
            isPrivate
          }
        }
      }
    `;
    const data = await graphql(query);
    page = data?.pages?.singleByPath;
    // Flatten tags
    if (page && page.tags) {
      page.tags = page.tags.map(t => t.tag);
    }
  }

  if (!page) {
    throw new Error(`Page not found: ${idOrPath}`);
  }

  // Get children if requested
  if (withChildren) {
    const childQuery = `
      query {
        pages {
          list {
            id
            path
            title
          }
        }
      }
    `;
    const childData = await graphql(childQuery);
    const allPages = childData?.pages?.list || [];
    page.children = allPages.filter(p =>
      p.path.startsWith(page.path + '/') && p.id !== page.id
    );
  }

  return page;
}

export async function createPage(path, title, options = {}) {
  const config = loadConfig();
  const {
    content = '',
    description = '',
    tags = [],
    locale = config.defaultLocale || 'en',
    editor = config.defaultEditor || 'markdown',
    isPublished = true,
    isPrivate = false
  } = options;

  // Validate and sanitize inputs
  const safePath = sanitizeString(validatePath(path));
  const safeEditor = sanitizeString(editor);
  const safeLocale = sanitizeString(locale);

  const mutation = `
    mutation {
      pages {
        create(
          content: ${JSON.stringify(content)}
          description: ${JSON.stringify(description)}
          editor: "${safeEditor}"
          isPrivate: ${isPrivate}
          isPublished: ${isPublished}
          locale: "${safeLocale}"
          path: "${safePath}"
          tags: ${JSON.stringify(tags)}
          title: ${JSON.stringify(title)}
        ) {
          responseResult {
            succeeded
            errorCode
            message
          }
          page {
            id
            path
            title
          }
        }
      }
    }
  `;

  const data = await graphql(mutation);
  const result = data?.pages?.create;

  if (!result?.responseResult?.succeeded) {
    throw new Error(result?.responseResult?.message || 'Failed to create page');
  }

  return result.page;
}

export async function updatePage(id, options = {}) {
  const {
    content,
    title,
    description,
    tags,
    isPublished
  } = options;

  // Validate ID
  const validId = validateId(id);

  // First get current page data
  const currentPage = await getPage(validId);

  const mutation = `
    mutation {
      pages {
        update(
          id: ${validId}
          content: ${JSON.stringify(content ?? currentPage.content)}
          description: ${JSON.stringify(description ?? currentPage.description ?? '')}
          isPublished: ${isPublished ?? currentPage.isPublished}
          tags: ${JSON.stringify(tags ?? currentPage.tags ?? [])}
          title: ${JSON.stringify(title ?? currentPage.title)}
        ) {
          responseResult {
            succeeded
            errorCode
            message
          }
          page {
            id
            path
            title
            updatedAt
          }
        }
      }
    }
  `;

  const data = await graphql(mutation);
  const result = data?.pages?.update;

  if (!result?.responseResult?.succeeded) {
    throw new Error(result?.responseResult?.message || 'Failed to update page');
  }

  return result.page;
}

export async function movePage(id, newPath, options = {}) {
  const config = loadConfig();
  const { locale = config.defaultLocale || 'en' } = options;

  // Validate and sanitize inputs
  const validId = validateId(id);
  const safePath = sanitizeString(validatePath(newPath));
  const safeLocale = sanitizeString(locale);

  const mutation = `
    mutation {
      pages {
        move(
          id: ${validId}
          destinationPath: "${safePath}"
          destinationLocale: "${safeLocale}"
        ) {
          responseResult {
            succeeded
            errorCode
            message
          }
        }
      }
    }
  `;

  const data = await graphql(mutation);
  const result = data?.pages?.move;

  if (!result?.responseResult?.succeeded) {
    throw new Error(result?.responseResult?.message || 'Failed to move page');
  }

  return { success: true, newPath };
}

export async function deletePage(id, options = {}) {
  const validId = validateId(id);

  const mutation = `
    mutation {
      pages {
        delete(id: ${validId}) {
          responseResult {
            succeeded
            errorCode
            message
          }
        }
      }
    }
  `;

  const data = await graphql(mutation);
  const result = data?.pages?.delete;

  if (!result?.responseResult?.succeeded) {
    throw new Error(result?.responseResult?.message || 'Failed to delete page');
  }

  return { success: true, id };
}

// ============ TAGS ============

export async function listTags() {
  const query = `
    query {
      pages {
        tags {
          id
          tag
          title
          createdAt
          updatedAt
        }
      }
    }
  `;

  const data = await graphql(query);
  return data?.pages?.tags || [];
}

// ============ ASSETS ============

export async function listAssets(options = {}) {
  const { folder = '', limit = 50 } = options;

  const query = `
    query {
      assets {
        list(folderId: 0, kind: ALL) {
          id
          filename
          ext
          kind
          mime
          fileSize
          createdAt
          updatedAt
        }
      }
    }
  `;

  const data = await graphql(query);
  let assets = data?.assets?.list || [];

  // Filter by folder path if specified
  if (folder) {
    assets = assets.filter(a => a.filename.startsWith(folder));
  }

  return assets.slice(0, limit);
}

export async function uploadAsset(filePath, options = {}) {
  const { folder = '', rename } = options;

  const filename = rename || basename(filePath);
  const stats = statSync(filePath);

  const form = new FormData();
  form.append('mediaUpload', createReadStream(filePath), {
    filename,
    contentType: 'application/octet-stream'
  });

  const config = loadConfig();
  const response = await axios.post(
    `${config.url}/u`,
    form,
    {
      headers: {
        ...form.getHeaders(),
        'Authorization': `Bearer ${config.apiToken}`
      }
    }
  );

  return response.data;
}

export async function deleteAsset(id) {
  const validId = validateId(id);

  const mutation = `
    mutation {
      assets {
        deleteAsset(id: ${validId}) {
          responseResult {
            succeeded
            errorCode
            message
          }
        }
      }
    }
  `;

  const data = await graphql(mutation);
  const result = data?.assets?.deleteAsset;

  if (!result?.responseResult?.succeeded) {
    throw new Error(result?.responseResult?.message || 'Failed to delete asset');
  }

  return { success: true, id };
}

// ============ SYSTEM ============

export async function getHealth() {
  const query = `
    query {
      system {
        info {
          configFile
          currentVersion
          latestVersion
          operatingSystem
          hostname
          platform
        }
      }
    }
  `;

  const data = await graphql(query);
  return data?.system?.info;
}

export async function getStats() {
  const query = `
    query {
      pages {
        list {
          id
          locale
          isPublished
          tags
        }
      }
    }
  `;

  const data = await graphql(query);
  const pages = data?.pages?.list || [];

  // Calculate stats
  const stats = {
    totalPages: pages.length,
    publishedPages: pages.filter(p => p.isPublished).length,
    draftPages: pages.filter(p => !p.isPublished).length,
    locales: {},
    topTags: {}
  };

  pages.forEach(p => {
    // Count by locale
    stats.locales[p.locale] = (stats.locales[p.locale] || 0) + 1;

    // Count tags
    (p.tags || []).forEach(tag => {
      stats.topTags[tag] = (stats.topTags[tag] || 0) + 1;
    });
  });

  return stats;
}

// ============ VERSIONS ============

export async function getPageVersions(id) {
  const validId = validateId(id);

  const query = `
    query {
      pages {
        history(id: ${validId}) {
          versionId
          versionDate
          authorName
          actionType
        }
      }
    }
  `;

  const data = await graphql(query);
  return data?.pages?.history || [];
}

export async function revertPage(pageId, versionId) {
  const validPageId = validateId(pageId);
  const validVersionId = validateId(versionId);

  const mutation = `
    mutation {
      pages {
        restore(pageId: ${validPageId}, versionId: ${validVersionId}) {
          responseResult {
            succeeded
            errorCode
            message
          }
        }
      }
    }
  `;

  const data = await graphql(mutation);
  const result = data?.pages?.restore;

  if (!result?.responseResult?.succeeded) {
    throw new Error(result?.responseResult?.message || 'Failed to revert page');
  }

  return { success: true, pageId, versionId };
}

export { GraphQLError };

export default {
  listPages,
  searchPages,
  getPage,
  createPage,
  updatePage,
  movePage,
  deletePage,
  listTags,
  listAssets,
  uploadAsset,
  deleteAsset,
  getHealth,
  getStats,
  getPageVersions,
  revertPage
};


path: lib/config.js
content: import { readFileSync, existsSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';

const CONFIG_PATH = join(homedir(), '.config', 'wikijs.json');

let cachedConfig = null;

export function loadConfig() {
  if (cachedConfig) return cachedConfig;

  if (!existsSync(CONFIG_PATH)) {
    throw new Error(`Config file not found: ${CONFIG_PATH}\nCopy wikijs.example.json to ${CONFIG_PATH} and configure it.`);
  }

  try {
    const content = readFileSync(CONFIG_PATH, 'utf-8');
    cachedConfig = JSON.parse(content);

    // Validate required fields
    if (!cachedConfig.url) {
      throw new Error('Missing "url" in config');
    }
    if (!cachedConfig.apiToken) {
      throw new Error('Missing "apiToken" in config');
    }

    // Expand ~ in paths
    if (cachedConfig.autoSync?.path) {
      cachedConfig.autoSync.path = expandPath(cachedConfig.autoSync.path);
    }
    if (cachedConfig.backup?.path) {
      cachedConfig.backup.path = expandPath(cachedConfig.backup.path);
    }

    return cachedConfig;
  } catch (err) {
    if (err.code === 'ENOENT') {
      throw new Error(`Config file not found: ${CONFIG_PATH}`);
    }
    throw new Error(`Failed to parse config: ${err.message}`);
  }
}

export function getConfigPath() {
  return CONFIG_PATH;
}

function expandPath(path) {
  if (path.startsWith('~/')) {
    return join(homedir(), path.slice(2));
  }
  return path;
}

export default { loadConfig, getConfigPath };


path: lib/helpers.js
content: import chalk from 'chalk';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';

// Global state
let verboseMode = false;
let debugMode = false;
let noColorMode = false;
let rateLimitMs = 0;

// ============ MODE SETTERS ============

export function setVerbose(enabled) {
  verboseMode = enabled;
}

export function setDebug(enabled) {
  debugMode = enabled;
  if (enabled) verboseMode = true;
}

export function setNoColor(enabled) {
  noColorMode = enabled;
  if (enabled) {
    chalk.level = 0;
  }
}

export function setRateLimit(ms) {
  rateLimitMs = ms;
}

// ============ LOGGING ============

export function debug(...args) {
  if (debugMode) {
    const prefix = noColorMode ? '[DEBUG]' : chalk.gray('[DEBUG]');
    console.error(prefix, ...args);
  }
}

export function verbose(...args) {
  if (verboseMode) {
    const prefix = noColorMode ? '[INFO]' : chalk.blue('[INFO]');
    console.error(prefix, ...args);
  }
}

// ============ RATE LIMITING ============

let lastRequestTime = 0;

export async function rateLimit() {
  if (rateLimitMs <= 0) return;

  const now = Date.now();
  const elapsed = now - lastRequestTime;

  if (elapsed < rateLimitMs) {
    const delay = rateLimitMs - elapsed;
    debug(`Rate limiting: waiting ${delay}ms`);
    await sleep(delay);
  }

  lastRequestTime = Date.now();
}

export function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// ============ CACHING ============

const CACHE_DIR = join(homedir(), '.cache', 'wikijs-cli');
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes default

function getCachePath(key) {
  return join(CACHE_DIR, `${key}.json`);
}

export function getCached(key) {
  try {
    const cachePath = getCachePath(key);
    if (!existsSync(cachePath)) return null;

    const data = JSON.parse(readFileSync(cachePath, 'utf-8'));

    // Check TTL
    if (Date.now() - data.timestamp > (data.ttl || CACHE_TTL)) {
      debug(`Cache expired for ${key}`);
      return null;
    }

    debug(`Cache hit for ${key}`);
    return data.value;
  } catch (err) {
    debug(`Cache read error: ${err.message}`);
    return null;
  }
}

export function setCache(key, value, ttl = CACHE_TTL) {
  try {
    mkdirSync(CACHE_DIR, { recursive: true });
    const cachePath = getCachePath(key);
    const data = {
      timestamp: Date.now(),
      ttl,
      value
    };
    writeFileSync(cachePath, JSON.stringify(data));
    debug(`Cached ${key}`);
  } catch (err) {
    debug(`Cache write error: ${err.message}`);
  }
}

export async function clearCache() {
  try {
    const { rmSync } = await import('fs');
    rmSync(CACHE_DIR, { recursive: true, force: true });
    verbose('Cache cleared');
  } catch (err) {
    debug(`Cache clear error: ${err.message}`);
  }
}

// ============ PROGRESS ============

export function createProgress(total, label = 'Processing') {
  let current = 0;
  const startTime = Date.now();

  return {
    increment(amount = 1) {
      current += amount;
      this.render();
    },

    set(value) {
      current = value;
      this.render();
    },

    render() {
      if (!process.stderr.isTTY) return;

      const percent = Math.round((current / total) * 100);
      const barWidth = 30;
      const filled = Math.round((current / total) * barWidth);
      const empty = barWidth - filled;

      const bar = noColorMode
        ? `[${'='.repeat(filled)}${' '.repeat(empty)}]`
        : `[${chalk.green('█'.repeat(filled))}${chalk.gray('░'.repeat(empty))}]`;

      const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
      const status = `${bar} ${percent}% (${current}/${total}) ${elapsed}s`;

      process.stderr.write(`\r${label}: ${status}`);
    },

    done(message) {
      if (process.stderr.isTTY) {
        process.stderr.write('\r' + ' '.repeat(80) + '\r');
      }
      if (message) {
        console.log(message);
      }
    }
  };
}

// ============ TREE RENDERING ============

export function renderTree(items, options = {}) {
  const {
    getPath = item => item.path,
    getLabel = item => item.title || item.path,
    getId = item => item.id
  } = options;

  // Build tree structure
  const root = { children: {}, items: [] };

  for (const item of items) {
    const path = getPath(item);
    const parts = path.split('/').filter(Boolean);

    let current = root;
    for (let i = 0; i < parts.length; i++) {
      const part = parts[i];
      if (!current.children[part]) {
        current.children[part] = { children: {}, items: [] };
      }
      current = current.children[part];
    }
    current.items.push(item);
  }

  // Render tree
  const lines = [];

  function renderNode(node, prefix = '', isLast = true, name = '') {
    const children = Object.entries(node.children);
    const hasItems = node.items.length > 0;

    // Render directory name
    if (name) {
      const connector = isLast ? '└── ' : '├── ';
      const dirIcon = noColorMode ? '[D]' : chalk.blue('📁');
      lines.push(`${prefix}${connector}${dirIcon} ${name}/`);
    }

    const newPrefix = prefix + (name ? (isLast ? '    ' : '│   ') : '');

    // Render items (pages) in this directory
    const totalChildren = children.length + node.items.length;
    node.items.forEach((item, idx) => {
      const isLastItem = idx === node.items.length - 1 && children.length === 0;
      const connector = isLastItem ? '└── ' : '├── ';
      const pageIcon = noColorMode ? '[P]' : chalk.green('📄');
      const label = getLabel(item);
      const id = getId(item);
      const idStr = noColorMode ? `(${id})` : chalk.gray(`(${id})`);
      lines.push(`${newPrefix}${connector}${pageIcon} ${label} ${idStr}`);
    });

    // Render subdirectories
    children.forEach(([childName, childNode], idx) => {
      const isLastChild = idx === children.length - 1;
      renderNode(childNode, newPrefix, isLastChild, childName);
    });
  }

  renderNode(root);
  return lines.join('\n');
}

// ============ DIFF ============

export function diffStrings(str1, str2) {
  const lines1 = str1.split('\n');
  const lines2 = str2.split('\n');
  const result = [];

  const maxLen = Math.max(lines1.length, lines2.length);

  for (let i = 0; i < maxLen; i++) {
    const line1 = lines1[i];
    const line2 = lines2[i];

    if (line1 === undefined) {
      // Added line
      result.push({
        type: 'add',
        lineNum: i + 1,
        content: line2
      });
    } else if (line2 === undefined) {
      // Removed line
      result.push({
        type: 'remove',
        lineNum: i + 1,
        content: line1
      });
    } else if (line1 !== line2) {
      // Changed line
      result.push({
        type: 'remove',
        lineNum: i + 1,
        content: line1
      });
      result.push({
        type: 'add',
        lineNum: i + 1,
        content: line2
      });
    } else {
      // Unchanged - only show in context
      result.push({
        type: 'unchanged',
        lineNum: i + 1,
        content: line1
      });
    }
  }

  return result;
}

export function formatDiff(diff, contextLines = 3) {
  const lines = [];
  let lastPrintedLine = -contextLines - 1;

  for (let i = 0; i < diff.length; i++) {
    const item = diff[i];

    if (item.type === 'unchanged') {
      // Check if we're within context of a change
      const nearChange = diff.slice(Math.max(0, i - contextLines), i + contextLines + 1)
        .some(d => d.type !== 'unchanged');

      if (!nearChange) continue;
    }

    // Add separator if there's a gap
    if (item.lineNum > lastPrintedLine + 1 && lastPrintedLine > 0) {
      lines.push(noColorMode ? '...' : chalk.gray('...'));
    }

    const lineNum = String(item.lineNum).padStart(4);

    switch (item.type) {
      case 'add':
        lines.push(noColorMode
          ? `+ ${lineNum} | ${item.content}`
          : chalk.green(`+ ${lineNum} | ${item.content}`));
        break;
      case 'remove':
        lines.push(noColorMode
          ? `- ${lineNum} | ${item.content}`
          : chalk.red(`- ${lineNum} | ${item.content}`));
        break;
      default:
        lines.push(noColorMode
          ? `  ${lineNum} | ${item.content}`
          : chalk.gray(`  ${lineNum} | ${item.content}`));
    }

    lastPrintedLine = item.lineNum;
  }

  return lines.join('\n');
}

// ============ LINK EXTRACTION ============

export function extractLinks(markdown) {
  const links = [];

  // Markdown links: [text](url)
  const mdLinkRegex = /\[([^\]]*)\]\(([^)]+)\)/g;
  let match;
  while ((match = mdLinkRegex.exec(markdown)) !== null) {
    links.push({
      text: match[1],
      url: match[2],
      type: 'markdown'
    });
  }

  // Wiki-style links: [[page]] or [[page|text]]
  const wikiLinkRegex = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
  while ((match = wikiLinkRegex.exec(markdown)) !== null) {
    links.push({
      text: match[2] || match[1],
      url: match[1],
      type: 'wiki'
    });
  }

  return links;
}

export function isInternalLink(url) {
  // Internal links don't start with http/https/mailto/etc
  return !url.match(/^(https?:|mailto:|tel:|ftp:|#)/i);
}

// ============ MARKDOWN LINTING ============

export function lintMarkdown(content) {
  const issues = [];
  const lines = content.split('\n');

  lines.forEach((line, idx) => {
    const lineNum = idx + 1;

    // Check for trailing whitespace
    if (line.match(/\s+$/)) {
      issues.push({
        line: lineNum,
        type: 'warning',
        rule: 'no-trailing-spaces',
        message: 'Trailing whitespace'
      });
    }

    // Check for multiple consecutive blank lines
    if (idx > 0 && line === '' && lines[idx - 1] === '') {
      issues.push({
        line: lineNum,
        type: 'warning',
        rule: 'no-multiple-blanks',
        message: 'Multiple consecutive blank lines'
      });
    }

    // Check for headings without space after #
    if (line.match(/^#+[^#\s]/)) {
      issues.push({
        line: lineNum,
        type: 'error',
        rule: 'heading-space',
        message: 'Missing space after heading markers'
      });
    }

    // Check for very long lines (>120 chars, excluding links)
    if (line.length > 120 && !line.includes('http')) {
      issues.push({
        line: lineNum,
        type: 'warning',
        rule: 'line-length',
        message: `Line too long (${line.length} > 120 characters)`
      });
    }

    // Check for tabs (prefer spaces)
    if (line.includes('\t')) {
      issues.push({
        line: lineNum,
        type: 'warning',
        rule: 'no-tabs',
        message: 'Tab character found (prefer spaces)'
      });
    }

    // Check for broken link syntax
    if (line.match(/\[[^\]]*\]\([^)]*$/)) {
      issues.push({
        line: lineNum,
        type: 'error',
        rule: 'valid-link',
        message: 'Unclosed link syntax'
      });
    }
  });

  // Check document structure
  const headings = lines.filter(l => l.match(/^#{1,6}\s/));
  if (headings.length > 0 && !headings[0].match(/^#\s/)) {
    issues.push({
      line: 1,
      type: 'warning',
      rule: 'first-heading-h1',
      message: 'First heading should be H1'
    });
  }

  // Check for empty document
  const nonEmptyLines = lines.filter(l => l.trim().length > 0);
  if (nonEmptyLines.length === 0) {
    issues.push({
      line: 1,
      type: 'error',
      rule: 'no-empty',
      message: 'Document is empty'
    });
  }

  return {
    valid: issues.filter(i => i.type === 'error').length === 0,
    errors: issues.filter(i => i.type === 'error'),
    warnings: issues.filter(i => i.type === 'warning'),
    all: issues
  };
}

export function formatLintResults(results) {
  const lines = [];

  if (results.errors.length > 0) {
    lines.push(noColorMode ? 'Errors:' : chalk.red('Errors:'));
    results.errors.forEach(e => {
      lines.push(`  Line ${e.line}: ${e.message} (${e.rule})`);
    });
  }

  if (results.warnings.length > 0) {
    lines.push(noColorMode ? 'Warnings:' : chalk.yellow('Warnings:'));
    results.warnings.forEach(w => {
      lines.push(`  Line ${w.line}: ${w.message} (${w.rule})`);
    });
  }

  if (results.valid && results.warnings.length === 0) {
    lines.push(noColorMode ? 'No issues found' : chalk.green('No issues found'));
  }

  return lines.join('\n');
}

// ============ OFFLINE MODE ============

let offlineMode = false;
const OFFLINE_CACHE_FILE = join(CACHE_DIR, 'offline-data.json');

export function setOfflineMode(enabled) {
  offlineMode = enabled;
  if (enabled) {
    verbose('Offline mode enabled');
  }
}

export function isOfflineMode() {
  return offlineMode;
}

export function getOfflineData() {
  try {
    if (!existsSync(OFFLINE_CACHE_FILE)) return null;
    const data = JSON.parse(readFileSync(OFFLINE_CACHE_FILE, 'utf-8'));
    return data;
  } catch (err) {
    debug(`Failed to read offline data: ${err.message}`);
    return null;
  }
}

export function saveOfflineData(data) {
  try {
    mkdirSync(CACHE_DIR, { recursive: true });
    const offlineData = {
      timestamp: Date.now(),
      pages: data.pages || [],
      tags: data.tags || []
    };
    writeFileSync(OFFLINE_CACHE_FILE, JSON.stringify(offlineData, null, 2));
    debug('Offline data saved');
  } catch (err) {
    debug(`Failed to save offline data: ${err.message}`);
  }
}

export default {
  setVerbose,
  setDebug,
  setNoColor,
  setRateLimit,
  debug,
  verbose,
  rateLimit,
  sleep,
  getCached,
  setCache,
  clearCache,
  createProgress,
  renderTree,
  diffStrings,
  formatDiff,
  extractLinks,
  isInternalLink,
  lintMarkdown,
  formatLintResults,
  setOfflineMode,
  isOfflineMode,
  getOfflineData,
  saveOfflineData
};


path: lib/utils.js
content: import chalk from 'chalk';
import { table } from 'table';

export function formatTable(data, columns) {
  if (!data || data.length === 0) {
    return chalk.yellow('No results found.');
  }

  const headers = columns.map(c => chalk.bold(c.header));
  const rows = data.map(item => columns.map(c => {
    const value = c.accessor(item);
    return c.format ? c.format(value) : String(value ?? '');
  }));

  return table([headers, ...rows], {
    border: {
      topBody: '─',
      topJoin: '┬',
      topLeft: '┌',
      topRight: '┐',
      bottomBody: '─',
      bottomJoin: '┴',
      bottomLeft: '└',
      bottomRight: '┘',
      bodyLeft: '│',
      bodyRight: '│',
      bodyJoin: '│',
      joinBody: '─',
      joinLeft: '├',
      joinRight: '┤',
      joinJoin: '┼'
    }
  });
}

export function formatJson(data) {
  return JSON.stringify(data, null, 2);
}

export function truncate(str, maxLength = 50) {
  if (!str) return '';
  str = String(str);
  if (str.length <= maxLength) return str;
  return str.slice(0, maxLength - 3) + '...';
}

export function formatDate(dateStr) {
  if (!dateStr) return '';
  const date = new Date(dateStr);
  return date.toLocaleString('fr-FR', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit'
  });
}

export function formatBytes(bytes) {
  if (bytes === 0) return '0 B';
  const k = 1024;
  const sizes = ['B', 'KB', 'MB', 'GB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

export function success(message) {
  console.log(chalk.green('✓') + ' ' + message);
}

export function error(message) {
  console.error(chalk.red('✗') + ' ' + message);
}

export function warn(message) {
  console.log(chalk.yellow('⚠') + ' ' + message);
}

export function info(message) {
  console.log(chalk.blue('ℹ') + ' ' + message);
}

export function parseIdOrPath(input) {
  // If it's a number, return as ID
  const num = parseInt(input, 10);
  if (!isNaN(num) && String(num) === input) {
    return { type: 'id', value: num };
  }
  // Otherwise treat as path
  return { type: 'path', value: input };
}

export function parseTags(tagsStr) {
  if (!tagsStr) return [];
  return tagsStr.split(',').map(t => t.trim()).filter(Boolean);
}

export async function readStdin() {
  return new Promise((resolve) => {
    let data = '';
    process.stdin.setEncoding('utf8');
    process.stdin.on('readable', () => {
      let chunk;
      while ((chunk = process.stdin.read()) !== null) {
        data += chunk;
      }
    });
    process.stdin.on('end', () => {
      resolve(data);
    });
  });
}

export default {
  formatTable,
  formatJson,
  truncate,
  formatDate,
  formatBytes,
  success,
  error,
  warn,
  info,
  parseIdOrPath,
  parseTags,
  readStdin
};


path: package.json
content: {
  "name": "wikijs-cli",
  "version": "1.4.0",
  "description": "A comprehensive command-line interface for Wiki.js",
  "type": "module",
  "main": "bin/wikijs",
  "bin": {
    "wikijs": "./bin/wikijs"
  },
  "scripts": {
    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
    "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
    "link": "npm link"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/hopyky/wikijs-cli.git"
  },
  "homepage": "https://github.com/hopyky/wikijs-cli#readme",
  "bugs": {
    "url": "https://github.com/hopyky/wikijs-cli/issues"
  },
  "dependencies": {
    "axios": "^1.6.0",
    "chalk": "^5.3.0",
    "commander": "^11.0.0",
    "form-data": "^4.0.0",
    "inquirer": "^9.2.0",
    "ora": "^7.0.0",
    "table": "^6.8.0"
  },
  "engines": {
    "node": ">=18.0.0"
  },
  "keywords": [
    "wikijs",
    "wiki",
    "cli",
    "wiki.js",
    "graphql",
    "markdown",
    "documentation",
    "knowledge-base"
  ],
  "author": "OpenClaw",
  "license": "MIT",
  "devDependencies": {
    "jest": "^29.7.0"
  },
  "jest": {
    "testEnvironment": "node",
    "transform": {},
    "testMatch": ["**/tests/**/*.test.js"],
    "collectCoverageFrom": ["lib/**/*.js"],
    "coverageDirectory": "coverage",
    "moduleFileExtensions": ["js", "mjs"]
  }
}


path: tests/api.test.js
content: // Tests for API validation logic
// Note: These tests use mocked axios to test the API module

import { jest } from '@jest/globals';

// We need to test the validation functions indirectly since they're not exported
// This file tests basic module loading and structure

describe('API Module', () => {
  // Mock config before importing API
  jest.unstable_mockModule('../lib/config.js', () => ({
    loadConfig: () => ({
      url: 'http://test-wiki.local',
      apiToken: 'test-token'
    })
  }));

  describe('Module Loading', () => {
    test('exports expected functions', async () => {
      const api = await import('../lib/api.js');

      // Verify all expected exports exist
      expect(typeof api.listPages).toBe('function');
      expect(typeof api.searchPages).toBe('function');
      expect(typeof api.getPage).toBe('function');
      expect(typeof api.createPage).toBe('function');
      expect(typeof api.updatePage).toBe('function');
      expect(typeof api.movePage).toBe('function');
      expect(typeof api.deletePage).toBe('function');
      expect(typeof api.listTags).toBe('function');
      expect(typeof api.listAssets).toBe('function');
      expect(typeof api.uploadAsset).toBe('function');
      expect(typeof api.deleteAsset).toBe('function');
      expect(typeof api.getHealth).toBe('function');
      expect(typeof api.getStats).toBe('function');
      expect(typeof api.getPageVersions).toBe('function');
      expect(typeof api.revertPage).toBe('function');
    });
  });

  describe('Input Validation', () => {
    let api;

    beforeAll(async () => {
      // Mock axios to catch requests
      jest.unstable_mockModule('axios', () => ({
        default: {
          create: () => ({
            post: jest.fn().mockRejectedValue(new Error('Network disabled for testing')),
            interceptors: {
              response: { use: jest.fn() }
            }
          })
        }
      }));

      api = await import('../lib/api.js');
    });

    test('getPage rejects invalid ID format', async () => {
      // Test with invalid ID - this should fail validation
      await expect(api.getPage(-1)).rejects.toThrow();
    });

    test('deletePage rejects invalid ID', async () => {
      await expect(api.deletePage('invalid')).rejects.toThrow();
      await expect(api.deletePage(-5)).rejects.toThrow();
    });
  });
});

// Test the GraphQL error class behavior
describe('GraphQL Error Handling', () => {
  test('formats multiple error messages', () => {
    // This tests the error message formatting logic
    const errors = [
      { message: 'First error' },
      { message: 'Second error' }
    ];
    const combinedMessage = errors.map(e => e.message).join('; ');
    expect(combinedMessage).toBe('First error; Second error');
  });
});

// Test string sanitization patterns
describe('String Sanitization Patterns', () => {
  // These test the sanitization logic without importing the internal function
  const sanitize = (str) => {
    if (str === null || str === undefined) return '';
    return String(str)
      .replace(/\\/g, '\\\\')
      .replace(/"/g, '\\"')
      .replace(/\n/g, '\\n')
      .replace(/\r/g, '\\r')
      .replace(/\t/g, '\\t');
  };

  test('escapes backslashes', () => {
    expect(sanitize('path\\to\\file')).toBe('path\\\\to\\\\file');
  });

  test('escapes quotes', () => {
    expect(sanitize('say "hello"')).toBe('say \\"hello\\"');
  });

  test('escapes newlines', () => {
    expect(sanitize('line1\nline2')).toBe('line1\\nline2');
  });

  test('escapes carriage returns', () => {
    expect(sanitize('line1\rline2')).toBe('line1\\rline2');
  });

  test('escapes tabs', () => {
    expect(sanitize('col1\tcol2')).toBe('col1\\tcol2');
  });

  test('handles null and undefined', () => {
    expect(sanitize(null)).toBe('');
    expect(sanitize(undefined)).toBe('');
  });

  test('handles complex injection attempts', () => {
    const malicious = '"); DROP TABLE pages; --';
    const sanitized = sanitize(malicious);
    // The quote is escaped so the injection won't work
    expect(sanitized).toBe('\\"); DROP TABLE pages; --');
    // The raw unescaped ") pattern is neutralized - now it's \")
    expect(sanitized.startsWith('\\")')).toBe(true);
  });
});

// Test path validation patterns
describe('Path Validation Patterns', () => {
  const validatePath = (path) => {
    if (!path || typeof path !== 'string') {
      throw new Error('Path is required');
    }
    const cleanPath = path.startsWith('/') ? path.slice(1) : path;
    if (/[<>:"|?*]/.test(cleanPath)) {
      throw new Error(`Invalid characters in path: ${path}`);
    }
    return cleanPath;
  };

  test('removes leading slash', () => {
    expect(validatePath('/docs/api')).toBe('docs/api');
  });

  test('keeps path without leading slash', () => {
    expect(validatePath('docs/api')).toBe('docs/api');
  });

  test('rejects invalid characters', () => {
    expect(() => validatePath('doc<s')).toThrow();
    expect(() => validatePath('doc>s')).toThrow();
    expect(() => validatePath('doc:s')).toThrow();
    expect(() => validatePath('doc"s')).toThrow();
    expect(() => validatePath('doc|s')).toThrow();
    expect(() => validatePath('doc?s')).toThrow();
    expect(() => validatePath('doc*s')).toThrow();
  });

  test('rejects empty path', () => {
    expect(() => validatePath('')).toThrow('Path is required');
    expect(() => validatePath(null)).toThrow('Path is required');
  });
});

// Test ID validation patterns
describe('ID Validation Patterns', () => {
  const validateId = (id) => {
    const num = parseInt(id, 10);
    if (isNaN(num) || num < 1) {
      throw new Error(`Invalid ID: ${id}`);
    }
    return num;
  };

  test('accepts valid positive integers', () => {
    expect(validateId(1)).toBe(1);
    expect(validateId(123)).toBe(123);
    expect(validateId('456')).toBe(456);
  });

  test('rejects zero', () => {
    expect(() => validateId(0)).toThrow('Invalid ID: 0');
  });

  test('rejects negative numbers', () => {
    expect(() => validateId(-1)).toThrow('Invalid ID: -1');
    expect(() => validateId(-100)).toThrow('Invalid ID: -100');
  });

  test('rejects non-numeric strings', () => {
    expect(() => validateId('abc')).toThrow();
    // Note: parseInt('12abc', 10) returns 12, so this is accepted
    // This matches JavaScript's parseInt behavior
    expect(validateId('12abc')).toBe(12);
  });

  test('rejects NaN', () => {
    expect(() => validateId(NaN)).toThrow();
  });
});


path: tests/helpers.test.js
content: import {
  diffStrings,
  formatDiff,
  extractLinks,
  isInternalLink,
  lintMarkdown,
  renderTree,
  sleep
} from '../lib/helpers.js';

describe('diffStrings', () => {
  test('identifies unchanged lines', () => {
    const result = diffStrings('line1\nline2', 'line1\nline2');
    expect(result).toHaveLength(2);
    expect(result[0]).toEqual({ type: 'unchanged', lineNum: 1, content: 'line1' });
    expect(result[1]).toEqual({ type: 'unchanged', lineNum: 2, content: 'line2' });
  });

  test('identifies added lines', () => {
    const result = diffStrings('line1', 'line1\nline2');
    expect(result).toHaveLength(2);
    expect(result[0]).toEqual({ type: 'unchanged', lineNum: 1, content: 'line1' });
    expect(result[1]).toEqual({ type: 'add', lineNum: 2, content: 'line2' });
  });

  test('identifies removed lines', () => {
    const result = diffStrings('line1\nline2', 'line1');
    expect(result).toHaveLength(2);
    expect(result[0]).toEqual({ type: 'unchanged', lineNum: 1, content: 'line1' });
    expect(result[1]).toEqual({ type: 'remove', lineNum: 2, content: 'line2' });
  });

  test('identifies changed lines', () => {
    const result = diffStrings('old', 'new');
    expect(result).toHaveLength(2);
    expect(result[0]).toEqual({ type: 'remove', lineNum: 1, content: 'old' });
    expect(result[1]).toEqual({ type: 'add', lineNum: 1, content: 'new' });
  });
});

describe('extractLinks', () => {
  test('extracts markdown links', () => {
    const links = extractLinks('Check [this link](http://example.com) out');
    expect(links).toHaveLength(1);
    expect(links[0]).toEqual({
      text: 'this link',
      url: 'http://example.com',
      type: 'markdown'
    });
  });

  test('extracts wiki-style links', () => {
    const links = extractLinks('See [[some-page]] for more info');
    expect(links).toHaveLength(1);
    expect(links[0]).toEqual({
      text: 'some-page',
      url: 'some-page',
      type: 'wiki'
    });
  });

  test('extracts wiki links with custom text', () => {
    const links = extractLinks('See [[some-page|Custom Text]] here');
    expect(links).toHaveLength(1);
    expect(links[0]).toEqual({
      text: 'Custom Text',
      url: 'some-page',
      type: 'wiki'
    });
  });

  test('extracts multiple links', () => {
    const markdown = '[Link 1](/page1) and [Link 2](/page2) and [[wiki-link]]';
    const links = extractLinks(markdown);
    expect(links).toHaveLength(3);
  });

  test('returns empty array for no links', () => {
    const links = extractLinks('No links here');
    expect(links).toHaveLength(0);
  });
});

describe('isInternalLink', () => {
  test('identifies external http links', () => {
    expect(isInternalLink('http://example.com')).toBe(false);
    expect(isInternalLink('https://example.com')).toBe(false);
  });

  test('identifies mailto links as external', () => {
    expect(isInternalLink('mailto:[REDACTED_SECRET]')).toBe(false);
  });

  test('identifies internal relative links', () => {
    expect(isInternalLink('/some/path')).toBe(true);
    expect(isInternalLink('relative/path')).toBe(true);
    expect(isInternalLink('../parent/path')).toBe(true);
  });

  test('identifies anchor links as external', () => {
    expect(isInternalLink('#section')).toBe(false);
  });
});

describe('lintMarkdown', () => {
  test('validates clean markdown', () => {
    const result = lintMarkdown('# Heading\n\nSome content here.');
    expect(result.valid).toBe(true);
    expect(result.errors).toHaveLength(0);
  });

  test('detects trailing whitespace', () => {
    const result = lintMarkdown('Line with trailing space   \n');
    expect(result.warnings.some(w => w.rule === 'no-trailing-spaces')).toBe(true);
  });

  test('detects multiple blank lines', () => {
    const result = lintMarkdown('Line 1\n\n\nLine 2');
    expect(result.warnings.some(w => w.rule === 'no-multiple-blanks')).toBe(true);
  });

  test('detects missing space after heading marker', () => {
    const result = lintMarkdown('#No space');
    expect(result.errors.some(e => e.rule === 'heading-space')).toBe(true);
  });

  test('detects empty document', () => {
    const result = lintMarkdown('   \n  \n   ');
    expect(result.errors.some(e => e.rule === 'no-empty')).toBe(true);
  });

  test('detects tabs', () => {
    const result = lintMarkdown('Line with\ttab');
    expect(result.warnings.some(w => w.rule === 'no-tabs')).toBe(true);
  });

  test('warns if first heading is not H1', () => {
    const result = lintMarkdown('## Second level heading');
    expect(result.warnings.some(w => w.rule === 'first-heading-h1')).toBe(true);
  });

  test('returns correct structure', () => {
    const result = lintMarkdown('# Valid');
    expect(result).toHaveProperty('valid');
    expect(result).toHaveProperty('errors');
    expect(result).toHaveProperty('warnings');
    expect(result).toHaveProperty('all');
    expect(Array.isArray(result.errors)).toBe(true);
    expect(Array.isArray(result.warnings)).toBe(true);
  });
});

describe('renderTree', () => {
  test('renders single item', () => {
    const items = [{ id: 1, path: 'page', title: 'Page Title' }];
    const output = renderTree(items);
    expect(output).toContain('Page Title');
    expect(output).toContain('(1)');
  });

  test('renders nested structure', () => {
    const items = [
      { id: 1, path: 'docs/api', title: 'API Docs' },
      { id: 2, path: 'docs/guide', title: 'Guide' }
    ];
    const output = renderTree(items);
    expect(output).toContain('docs/');
    expect(output).toContain('API Docs');
    expect(output).toContain('Guide');
  });

  test('uses custom accessors', () => {
    const items = [{ customId: 99, customPath: 'test', customTitle: 'Test' }];
    const output = renderTree(items, {
      getId: item => item.customId,
      getPath: item => item.customPath,
      getLabel: item => item.customTitle
    });
    expect(output).toContain('Test');
    expect(output).toContain('(99)');
  });
});

describe('sleep', () => {
  test('delays execution', async () => {
    const start = Date.now();
    await sleep(50);
    const elapsed = Date.now() - start;
    expect(elapsed).toBeGreaterThanOrEqual(45); // Allow some margin
  });
});

describe('formatDiff', () => {
  test('formats diff output', () => {
    const diff = [
      { type: 'unchanged', lineNum: 1, content: 'same' },
      { type: 'remove', lineNum: 2, content: 'old' },
      { type: 'add', lineNum: 2, content: 'new' }
    ];
    const output = formatDiff(diff);
    expect(output).toContain('old');
    expect(output).toContain('new');
  });
});

// Tests for TOC generation logic
describe('TOC Generation', () => {
  const extractHeadings = (content, maxDepth = 6) => {
    const headingRegex = /^(#{1,6})\s+(.+)$/gm;
    const headings = [];
    let match;

    while ((match = headingRegex.exec(content)) !== null) {
      const level = match[1].length;
      if (level <= maxDepth) {
        const text = match[2].trim();
        const slug = text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-');
        headings.push({ level, text, slug });
      }
    }
    return headings;
  };

  test('extracts headings from markdown', () => {
    const content = '# Title\n\n## Section 1\n\n### Subsection\n\n## Section 2';
    const headings = extractHeadings(content);
    expect(headings).toHaveLength(4);
    expect(headings[0]).toEqual({ level: 1, text: 'Title', slug: 'title' });
    expect(headings[1]).toEqual({ level: 2, text: 'Section 1', slug: 'section-1' });
  });

  test('respects max depth', () => {
    const content = '# H1\n## H2\n### H3\n#### H4';
    const headings = extractHeadings(content, 2);
    expect(headings).toHaveLength(2);
  });

  test('generates correct slugs', () => {
    const content = '# Hello World!\n## Test: Section';
    const headings = extractHeadings(content);
    expect(headings[0].slug).toBe('hello-world');
    expect(headings[1].slug).toBe('test-section');
  });
});

// Tests for similarity calculation
describe('Content Similarity', () => {
  const getWords = (text) => {
    return text.toLowerCase()
      .replace(/[^\w\s]/g, '')
      .split(/\s+/)
      .filter(w => w.length > 3);
  };

  const similarity = (words1, words2) => {
    const set1 = new Set(words1);
    const set2 = new Set(words2);
    const intersection = [...set1].filter(w => set2.has(w));
    const union = new Set([...set1, ...set2]);
    return intersection.length / union.size;
  };

  test('extracts words correctly', () => {
    const words = getWords('Hello, World! This is a test.');
    expect(words).toContain('hello');
    expect(words).toContain('world');
    expect(words).toContain('this');
    expect(words).toContain('test');
    expect(words).not.toContain('is'); // Too short
    expect(words).not.toContain('a'); // Too short
  });

  test('calculates similarity of identical content', () => {
    const words = getWords('This is some test content');
    expect(similarity(words, words)).toBe(1);
  });

  test('calculates similarity of different content', () => {
    const words1 = getWords('This is about programming');
    const words2 = getWords('Cooking recipes for dinner');
    const sim = similarity(words1, words2);
    expect(sim).toBeLessThan(0.5);
  });

  test('calculates similarity of partially similar content', () => {
    const words1 = getWords('JavaScript programming tutorial');
    const words2 = getWords('JavaScript coding tutorial guide');
    const sim = similarity(words1, words2);
    expect(sim).toBeGreaterThan(0.3);
    expect(sim).toBeLessThan(1);
  });
});

// Tests for sitemap XML generation
describe('Sitemap Generation', () => {
  test('generates valid XML structure', () => {
    const pages = [
      { path: 'docs/api', updatedAt: '2026-01-15', isPublished: true },
      { path: 'docs/guide', updatedAt: '2026-01-20', isPublished: true }
    ];
    const baseUrl = 'https://wiki.example.com';

    const xml = [];
    xml.push('<?xml version="1.0" encoding="UTF-8"?>');
    xml.push('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">');
    for (const page of pages) {
      xml.push(`  <url><loc>${baseUrl}/${page.path}</loc></url>`);
    }
    xml.push('</urlset>');

    const output = xml.join('\n');
    expect(output).toContain('<?xml version="1.0"');
    expect(output).toContain('<urlset');
    expect(output).toContain('docs/api');
    expect(output).toContain('docs/guide');
    expect(output).toContain('</urlset>');
  });
});


path: tests/utils.test.js
content: import {
  formatJson,
  truncate,
  formatBytes,
  parseIdOrPath,
  parseTags
} from '../lib/utils.js';

describe('formatJson', () => {
  test('formats object as pretty JSON', () => {
    const result = formatJson({ key: 'value' });
    expect(result).toBe('{\n  "key": "value"\n}');
  });

  test('formats array as pretty JSON', () => {
    const result = formatJson([1, 2, 3]);
    expect(result).toBe('[\n  1,\n  2,\n  3\n]');
  });

  test('handles null and undefined', () => {
    expect(formatJson(null)).toBe('null');
    expect(formatJson(undefined)).toBe(undefined);
  });
});

describe('truncate', () => {
  test('returns original string if under max length', () => {
    expect(truncate('short', 10)).toBe('short');
  });

  test('truncates long strings with ellipsis', () => {
    expect(truncate('this is a very long string', 10)).toBe('this is...');
  });

  test('handles empty string', () => {
    expect(truncate('')).toBe('');
  });

  test('handles null/undefined', () => {
    expect(truncate(null)).toBe('');
    expect(truncate(undefined)).toBe('');
  });

  test('converts non-strings to string', () => {
    expect(truncate(12345, 10)).toBe('12345');
  });

  test('uses default max length of 50', () => {
    const longString = 'a'.repeat(60);
    const result = truncate(longString);
    expect(result.length).toBe(50);
    expect(result.endsWith('...')).toBe(true);
  });
});

describe('formatBytes', () => {
  test('formats 0 bytes', () => {
    expect(formatBytes(0)).toBe('0 B');
  });

  test('formats bytes', () => {
    expect(formatBytes(500)).toBe('500 B');
  });

  test('formats kilobytes', () => {
    expect(formatBytes(1024)).toBe('1 KB');
    expect(formatBytes(1536)).toBe('1.5 KB');
  });

  test('formats megabytes', () => {
    expect(formatBytes(1048576)).toBe('1 MB');
    expect(formatBytes(1572864)).toBe('1.5 MB');
  });

  test('formats gigabytes', () => {
    expect(formatBytes(1073741824)).toBe('1 GB');
  });
});

describe('parseIdOrPath', () => {
  test('parses numeric ID', () => {
    const result = parseIdOrPath('123');
    expect(result).toEqual({ type: 'id', value: 123 });
  });

  test('parses path string', () => {
    const result = parseIdOrPath('docs/api/overview');
    expect(result).toEqual({ type: 'path', value: 'docs/api/overview' });
  });

  test('treats number-like paths as paths', () => {
    const result = parseIdOrPath('123abc');
    expect(result).toEqual({ type: 'path', value: '123abc' });
  });

  test('handles paths starting with slash', () => {
    const result = parseIdOrPath('/some/path');
    expect(result).toEqual({ type: 'path', value: '/some/path' });
  });

  test('parses zero as ID', () => {
    const result = parseIdOrPath('0');
    expect(result).toEqual({ type: 'id', value: 0 });
  });
});

describe('parseTags', () => {
  test('parses comma-separated tags', () => {
    expect(parseTags('tag1,tag2,tag3')).toEqual(['tag1', 'tag2', 'tag3']);
  });

  test('trims whitespace from tags', () => {
    expect(parseTags('tag1 , tag2 , tag3')).toEqual(['tag1', 'tag2', 'tag3']);
  });

  test('filters empty tags', () => {
    expect(parseTags('tag1,,tag2,')).toEqual(['tag1', 'tag2']);
  });

  test('returns empty array for empty input', () => {
    expect(parseTags('')).toEqual([]);
    expect(parseTags(null)).toEqual([]);
    expect(parseTags(undefined)).toEqual([]);
  });

  test('handles single tag', () => {
    expect(parseTags('single')).toEqual(['single']);
  });
});


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