Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
- main
paths:
- 'packages/**'
- 'apps/**'
- '!**.md'
workflow_dispatch:

Expand Down Expand Up @@ -90,6 +91,14 @@ jobs:
working-directory: packages/esign
run: pnpx semantic-release

- name: Release cli
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
working-directory: apps/cli
run: pnpx semantic-release

# Sync stable back to main after stable release
# - name: Sync stable to main
# if: github.ref == 'refs/heads/stable'
Expand Down
14 changes: 14 additions & 0 deletions apps/cli/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# dependencies (bun install)
node_modules

# output
dist

# env files
.env

# caches
*.tsbuildinfo

# Finder (MacOS) folder config
.DS_Store
31 changes: 31 additions & 0 deletions apps/cli/.releaserc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/* eslint-env node */
const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH;

const config = {
branches: [
{ name: 'stable', channel: 'latest' },
{ name: 'main', prerelease: 'next', channel: 'next' },
],
tagFormat: 'cli-v${version}',
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/release-notes-generator',
['@semantic-release/npm', { npmPublish: true }],
],
};

const isPrerelease = config.branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease);

if (!isPrerelease) {
config.plugins.push([
'@semantic-release/git',
{
assets: ['package.json'],
message: 'chore(cli): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
},
]);
}

config.plugins.push('@semantic-release/github');

module.exports = config;
71 changes: 71 additions & 0 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# @superdoc-dev/cli

The command-line interface for [SuperDoc](https://superdoc.dev) — DOCX editing in your terminal.

```bash
npx @superdoc-dev/cli search "CONFIDENTIAL" ./legal/*.docx
```

## Commands

| Command | Status | Description |
|---------|--------|-------------|
| `search` | Available | Find text across documents |
| `replace` | Available | Find and replace text |
| `replace --track` | Coming soon | Replace with track changes |
| `read` | Available | Extract plain text |
| `diff` | Coming soon | Compare two documents |
| `convert` | Coming soon | DOCX ↔ HTML ↔ Markdown |
| `comments` | Coming soon | List, add, resolve comments |
| `accept` | Coming soon | Accept/reject track changes |

Powered by the SuperDoc document engine. Bulk operations, glob patterns, JSON output.

## Install

```bash
npm install -g @superdoc-dev/cli
```

Or run directly:

```bash
npx @superdoc-dev/cli <command>
```

## Usage

```bash
# Search across documents
superdoc search "indemnification" ./contracts/*.docx

# Find and replace
superdoc replace "ACME Corp" "Globex Inc" ./merger/*.docx

# Extract text
superdoc read ./proposal.docx

# JSON output for scripting
superdoc search "Article 7" ./**/*.docx --json
```

## Options

| Flag | Description |
|------|-------------|
| `--json` | Machine-readable output |
| `--help` | Show help |

## AI Integration

Works with AI coding assistants. Add a skill file so Claude Code, Cursor, etc. know to use `superdoc` for DOCX operations instead of python-docx.

*Skill setup guide coming soon.*

## Part of SuperDoc

This CLI is part of the [SuperDoc](https://github.com/superdoc-dev/superdoc) project — an open source document editor bringing Microsoft Word to the web. Use it alongside the editor, or standalone for document automation.

## License

AGPL-3.0 · [Enterprise license available](https://superdoc.dev)
33 changes: 33 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@superdoc-dev/cli",
"version": "0.0.1",
"type": "module",
"bin": {
"superdoc": "./dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"dev": "bun run src/index.ts",
"build": "bun build src/index.ts --outdir dist --target node --format esm",
"test": "bun test",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"format": "prettier --write .",
"typecheck": "tsc --noEmit",
"prepublishOnly": "pnpm run build",
"release": "pnpx semantic-release",
"release:dry-run": "pnpx semantic-release --dry-run"
},
"dependencies": {
"fast-glob": "^3.3.2",
"superdoc": "workspace:*"
},
"devDependencies": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:"
},
"module": "src/index.ts"
}
79 changes: 79 additions & 0 deletions apps/cli/src/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
import { copyFile, rm, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { read } from '../commands/read';
import { search } from '../commands/search';
import { replace } from '../commands/replace';

const TEST_DIR = join(import.meta.dir, 'fixtures');
const SAMPLE_DOC = join(TEST_DIR, 'sample.docx');

describe('CLI Commands', () => {
beforeAll(async () => {
await mkdir(TEST_DIR, { recursive: true });
// Copy a test document to our fixtures folder
const sourceDoc = join(import.meta.dir, '../../../../e2e-tests/test-data/basic-documents/advanced-text.docx');
await copyFile(sourceDoc, SAMPLE_DOC);
});

afterAll(async () => {
await rm(TEST_DIR, { recursive: true, force: true });
});

describe('read', () => {
test('reads document content', async () => {
const result = await read(SAMPLE_DOC);

expect(result).toHaveProperty('path', SAMPLE_DOC);
expect(result).toHaveProperty('content');
expect(typeof result.content).toBe('string');
expect(result.content.length).toBeGreaterThan(0);
});
});

describe('search', () => {
test('finds text in document', async () => {
const result = await search('the', [SAMPLE_DOC]);

expect(result).toHaveProperty('totalMatches');
expect(result).toHaveProperty('files');
expect(result.files).toHaveLength(1);
expect(result.files[0].path).toBe(SAMPLE_DOC);
});

test('returns empty for non-matching pattern', async () => {
const result = await search('xyz123nonexistent', [SAMPLE_DOC]);

expect(result.totalMatches).toBe(0);
expect(result.files).toHaveLength(0);
});
});

describe('replace', () => {
test('replaces text in document', async () => {
// Create a copy for replace test
const replaceCopy = join(TEST_DIR, 'replace-test.docx');
await copyFile(SAMPLE_DOC, replaceCopy);

// First verify the text exists
const beforeSearch = await search('the', [replaceCopy]);
const beforeCount = beforeSearch.totalMatches;

if (beforeCount > 0) {
// Replace and verify
const result = await replace('the', 'THE', [replaceCopy]);

expect(result).toHaveProperty('totalReplacements');
expect(result.totalReplacements).toBe(beforeCount);
expect(result.files).toHaveLength(1);
expect(result.files[0].replacements).toBe(beforeCount);

// Verify the replacement happened
const afterSearch = await search('THE', [replaceCopy]);
expect(afterSearch.totalMatches).toBe(beforeCount);
}

await rm(replaceCopy);
});
});
});
20 changes: 20 additions & 0 deletions apps/cli/src/commands/read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { closeDocument, getDocumentText, openDocument } from '../lib/editor';

export interface ReadResult {
path: string;
content: string;
}

/**
* Read a document and output its text content
*/
export async function read(filePath: string): Promise<ReadResult> {
const doc = await openDocument(filePath);

try {
const content = getDocumentText(doc);
return { path: filePath, content };
} finally {
closeDocument(doc);
}
}
49 changes: 49 additions & 0 deletions apps/cli/src/commands/replace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { closeDocument, openDocument, replaceInDocument, saveDocument } from '../lib/editor';

export interface ReplaceFileResult {
path: string;
replacements: number;
}

export interface ReplaceResult {
find: string;
replace: string;
files: ReplaceFileResult[];
totalReplacements: number;
}

/**
* Replace pattern in a single file
*/
async function replaceInFile(filePath: string, find: string, replace: string): Promise<ReplaceFileResult> {
const doc = await openDocument(filePath);

try {
const replacements = replaceInDocument(doc, find, replace);

if (replacements > 0) {
await saveDocument(doc);
}

return { path: filePath, replacements };
} finally {
closeDocument(doc);
}
}

/**
* Replace a pattern across multiple files
*/
export async function replace(find: string, replaceWith: string, filePaths: string[]): Promise<ReplaceResult> {
const results = await Promise.all(filePaths.map((fp) => replaceInFile(fp, find, replaceWith)));

const filesWithReplacements = results.filter((r) => r.replacements > 0);
const totalReplacements = results.reduce((sum, r) => sum + r.replacements, 0);

return {
find,
replace: replaceWith,
files: filesWithReplacements,
totalReplacements,
};
}
Loading
Loading