doc-syncer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/doc-syncer.config.example.yml +28 -0
- package/package.json +47 -0
- package/src/cli.ts +56 -0
- package/src/doc-sync.ts +475 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrii Orlov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# doc-sync
|
|
2
|
+
|
|
3
|
+
AI-powered documentation sync using Claude Code or Codex.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
Code changes (PR/branch) → Agent analyzes → Docs updated
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
### Via npm (recommended)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g doc-syncer
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### From source
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
git clone https://github.com/orlan0045/doc-syncer.git
|
|
21
|
+
cd doc-syncer
|
|
22
|
+
bun install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Prerequisites
|
|
26
|
+
|
|
27
|
+
You need one of these AI agents installed:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Option 1: Claude Code CLI (agent: claude)
|
|
31
|
+
npm install -g @anthropic-ai/claude-code
|
|
32
|
+
claude # authenticate once
|
|
33
|
+
|
|
34
|
+
# Option 2: Codex CLI (agent: codex)
|
|
35
|
+
# Follow Codex CLI installation instructions
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Setup
|
|
39
|
+
|
|
40
|
+
Download the example config and customize it:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Download example config
|
|
44
|
+
curl -o doc-syncer.config.yml https://raw.githubusercontent.com/orlan0045/doc-syncer/main/doc-syncer.config.example.yml
|
|
45
|
+
|
|
46
|
+
# Edit with your repo paths
|
|
47
|
+
nano doc-syncer.config.yml
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Example configuration:
|
|
51
|
+
|
|
52
|
+
```yaml
|
|
53
|
+
agent: claude
|
|
54
|
+
base_branch: main
|
|
55
|
+
|
|
56
|
+
modes:
|
|
57
|
+
frontend:
|
|
58
|
+
default: true
|
|
59
|
+
code_repo: /path/to/your/code-repo
|
|
60
|
+
docs_repo: /path/to/your/docs-repo
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
### Option 1: Config file (recommended)
|
|
66
|
+
|
|
67
|
+
Run:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
doc-syncer sync # run it
|
|
71
|
+
doc-syncer sync --dry-run # preview only
|
|
72
|
+
doc-syncer sync --mode backend # use specific mode from config
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Option 2: CLI flags
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
doc-syncer sync --code ~/dev/my-app --docs ~/dev/my-app-docs
|
|
79
|
+
doc-syncer sync --code ~/dev/my-app --docs ~/dev/my-app-docs --branch feature/xyz
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## What Happens
|
|
83
|
+
|
|
84
|
+
1. Gets git diff from your feature branch
|
|
85
|
+
2. Passes diff + docs repo access to the selected agent
|
|
86
|
+
3. Agent explores docs, understands style, updates what's relevant
|
|
87
|
+
4. You review with `git diff`
|
|
88
|
+
|
|
89
|
+
## Options
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
-m, --mode Mode preset to use (from config file)
|
|
93
|
+
-c, --code-repo Path to code repository
|
|
94
|
+
-d, --docs-repo Path to documentation repository
|
|
95
|
+
-b, --branch Feature branch (default: current)
|
|
96
|
+
--base Base branch (default: main)
|
|
97
|
+
--config Config file (default: doc-syncer.config.yml)
|
|
98
|
+
--dry-run Preview without running
|
|
99
|
+
--agent AI agent to run (claude | codex)
|
|
100
|
+
-h, --help Show help
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## After Running
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
cd /path/to/docs-repo
|
|
107
|
+
git diff # review changes
|
|
108
|
+
git add -A && git commit -m "docs: sync with feature/xyz"
|
|
109
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# doc-syncer configuration example
|
|
2
|
+
# Copy this file to doc-syncer.config.yml and customize for your repos
|
|
3
|
+
|
|
4
|
+
# Which AI agent to run: claude | codex
|
|
5
|
+
agent: claude
|
|
6
|
+
|
|
7
|
+
# Branch to compare against
|
|
8
|
+
base_branch: main
|
|
9
|
+
|
|
10
|
+
# Tool permissions for the agent (default: Read, Write, Edit)
|
|
11
|
+
permissions:
|
|
12
|
+
- Read
|
|
13
|
+
- Write
|
|
14
|
+
- Edit
|
|
15
|
+
|
|
16
|
+
# Modes allow you to configure multiple code/docs repo pairs
|
|
17
|
+
# Use --mode <name> to select which mode to run
|
|
18
|
+
modes:
|
|
19
|
+
# Example mode 1: Frontend app
|
|
20
|
+
frontend:
|
|
21
|
+
default: true # Use this mode by default if --mode not specified
|
|
22
|
+
code_repo: /path/to/your/code-repo
|
|
23
|
+
docs_repo: /path/to/your/docs-repo
|
|
24
|
+
|
|
25
|
+
# Example mode 2: Backend API
|
|
26
|
+
backend:
|
|
27
|
+
code_repo: /path/to/your/api-repo
|
|
28
|
+
docs_repo: /path/to/your/api-docs-repo
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "doc-syncer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI-powered documentation sync using Claude Code or Codex",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Andrii Orlov",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"documentation",
|
|
10
|
+
"sync",
|
|
11
|
+
"ai",
|
|
12
|
+
"claude",
|
|
13
|
+
"codex",
|
|
14
|
+
"docs",
|
|
15
|
+
"git",
|
|
16
|
+
"automation"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/orlan0045/doc-syncer.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/orlan0045/doc-syncer#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/orlan0045/doc-syncer/issues"
|
|
25
|
+
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"doc-syncer": "src/cli.ts"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src/",
|
|
31
|
+
"doc-syncer.config.example.yml",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"init": "cp doc-syncer.config.example.yml doc-syncer.config.yml",
|
|
37
|
+
"sync": "bun src/doc-sync.ts",
|
|
38
|
+
"sync:dry": "bun src/doc-sync.ts --dry-run",
|
|
39
|
+
"prepublishOnly": "echo 'Ready to publish'"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"yaml": "^2.6.0"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20.0.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* doc-syncer CLI entry point
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* doc-syncer sync [options]
|
|
8
|
+
* doc-syncer --help
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const args = Bun.argv.slice(2);
|
|
12
|
+
const command = args[0];
|
|
13
|
+
|
|
14
|
+
if (!command || command === "--help" || command === "-h") {
|
|
15
|
+
printHelp();
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (command === "sync") {
|
|
20
|
+
// Remove 'sync' from args and run the sync script
|
|
21
|
+
Bun.argv.splice(2, 1);
|
|
22
|
+
await import("./doc-sync.ts");
|
|
23
|
+
} else {
|
|
24
|
+
console.error(`\n❌ Unknown command: ${command}\n`);
|
|
25
|
+
printHelp();
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function printHelp() {
|
|
30
|
+
console.log(`
|
|
31
|
+
doc-syncer: AI-powered documentation sync
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
doc-syncer sync [options]
|
|
35
|
+
|
|
36
|
+
Commands:
|
|
37
|
+
sync Sync documentation based on code changes
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
-m, --mode Mode preset to use (from config file)
|
|
41
|
+
-c, --code-repo Path to code repository
|
|
42
|
+
-d, --docs-repo Path to documentation repository
|
|
43
|
+
-b, --branch Feature branch to analyze (default: current)
|
|
44
|
+
--base Base branch to diff against (default: main)
|
|
45
|
+
--config Path to YAML config file (default: doc-syncer.config.yml)
|
|
46
|
+
--dry-run Preview without running the agent
|
|
47
|
+
--agent AI agent to run (claude | codex)
|
|
48
|
+
-h, --help Show this help
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
doc-syncer sync # Use default mode
|
|
52
|
+
doc-syncer sync --mode esign # Use specific mode
|
|
53
|
+
doc-syncer sync --mode esign --dry-run # Preview mode
|
|
54
|
+
doc-syncer sync --code ~/dev/myapp --docs ~/dev/myapp-docs
|
|
55
|
+
`);
|
|
56
|
+
}
|
package/src/doc-sync.ts
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* doc-sync: AI-powered documentation sync using Claude Code or Codex
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* bun sync --code ~/dev/app --docs ~/dev/app-docs
|
|
8
|
+
* bun sync --config doc-sync.yml
|
|
9
|
+
* bun sync --dry-run
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { $ } from "bun";
|
|
13
|
+
import { parseArgs } from "util";
|
|
14
|
+
import { resolve } from "path";
|
|
15
|
+
import { parse as parseYaml } from "yaml";
|
|
16
|
+
|
|
17
|
+
// ============ TYPES ============
|
|
18
|
+
|
|
19
|
+
interface Config {
|
|
20
|
+
codeRepo: string;
|
|
21
|
+
docsRepo: string;
|
|
22
|
+
baseBranch: string;
|
|
23
|
+
featureBranch?: string;
|
|
24
|
+
dryRun: boolean;
|
|
25
|
+
agent: "claude" | "codex";
|
|
26
|
+
permissions: string[];
|
|
27
|
+
mode?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ============ CONFIG ============
|
|
31
|
+
|
|
32
|
+
async function loadConfig(): Promise<Config> {
|
|
33
|
+
const { values } = parseArgs({
|
|
34
|
+
args: Bun.argv.slice(2),
|
|
35
|
+
options: {
|
|
36
|
+
"code-repo": { type: "string", short: "c" },
|
|
37
|
+
"code": { type: "string" },
|
|
38
|
+
"docs-repo": { type: "string", short: "d" },
|
|
39
|
+
"docs": { type: "string" },
|
|
40
|
+
"branch": { type: "string", short: "b" },
|
|
41
|
+
"base": { type: "string" },
|
|
42
|
+
"config": { type: "string" },
|
|
43
|
+
"dry-run": { type: "boolean" },
|
|
44
|
+
"agent": { type: "string" },
|
|
45
|
+
"mode": { type: "string", short: "m" },
|
|
46
|
+
"help": { type: "boolean", short: "h" },
|
|
47
|
+
},
|
|
48
|
+
allowPositionals: true,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (values.help) {
|
|
52
|
+
printHelp();
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Try config file first
|
|
57
|
+
const configPath = values.config || "doc-syncer.config.yml";
|
|
58
|
+
const configFile = Bun.file(configPath);
|
|
59
|
+
|
|
60
|
+
if (await configFile.exists()) {
|
|
61
|
+
const content = await configFile.text();
|
|
62
|
+
const yaml = parseYaml(content);
|
|
63
|
+
|
|
64
|
+
// Check if modes exist
|
|
65
|
+
if (yaml.modes && typeof yaml.modes === "object") {
|
|
66
|
+
const modeName = values.mode || findDefaultMode(yaml.modes);
|
|
67
|
+
|
|
68
|
+
if (!modeName) {
|
|
69
|
+
console.error("\n❌ No default mode found and no --mode specified.");
|
|
70
|
+
console.error(`\nAvailable modes: ${Object.keys(yaml.modes).join(", ")}`);
|
|
71
|
+
console.error(`\nSet default: true on a mode or use --mode <name>\n`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const mode = yaml.modes[modeName];
|
|
76
|
+
if (!mode) {
|
|
77
|
+
console.error(`\n❌ Mode "${modeName}" not found in config.`);
|
|
78
|
+
console.error(`\nAvailable modes: ${Object.keys(yaml.modes).join(", ")}\n`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Merge mode config with top-level defaults
|
|
83
|
+
const agent = parseAgent(values.agent ?? mode.agent ?? yaml.agent);
|
|
84
|
+
const permissions = parsePermissions(mode.permissions ?? yaml.permissions);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
codeRepo: resolve(mode.code_repo || mode.codeRepo),
|
|
88
|
+
docsRepo: resolve(mode.docs_repo || mode.docsRepo),
|
|
89
|
+
baseBranch: mode.base_branch || mode.baseBranch || yaml.base_branch || yaml.baseBranch || "main",
|
|
90
|
+
featureBranch: values.branch || mode.feature_branch || mode.featureBranch,
|
|
91
|
+
dryRun: values["dry-run"] || false,
|
|
92
|
+
agent,
|
|
93
|
+
permissions,
|
|
94
|
+
mode: modeName,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Fallback: no modes, use top-level config (backward compatibility)
|
|
99
|
+
const agent = parseAgent(values.agent ?? yaml.agent);
|
|
100
|
+
const permissions = parsePermissions(yaml.permissions);
|
|
101
|
+
return {
|
|
102
|
+
codeRepo: resolve(yaml.code_repo || yaml.codeRepo),
|
|
103
|
+
docsRepo: resolve(yaml.docs_repo || yaml.docsRepo),
|
|
104
|
+
baseBranch: yaml.base_branch || yaml.baseBranch || "main",
|
|
105
|
+
featureBranch: values.branch || yaml.feature_branch || yaml.featureBranch,
|
|
106
|
+
dryRun: values["dry-run"] || false,
|
|
107
|
+
agent,
|
|
108
|
+
permissions,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// CLI args
|
|
113
|
+
const codeRepo = values["code-repo"] || values.code;
|
|
114
|
+
const docsRepo = values["docs-repo"] || values.docs;
|
|
115
|
+
const agent = parseAgent(values.agent);
|
|
116
|
+
|
|
117
|
+
if (!codeRepo || !docsRepo) {
|
|
118
|
+
printHelp();
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
codeRepo: resolve(codeRepo),
|
|
124
|
+
docsRepo: resolve(docsRepo),
|
|
125
|
+
baseBranch: values.base || "main",
|
|
126
|
+
featureBranch: values.branch,
|
|
127
|
+
dryRun: values["dry-run"] || false,
|
|
128
|
+
agent,
|
|
129
|
+
permissions: parsePermissions(undefined),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function findDefaultMode(modes: Record<string, any>): string | null {
|
|
134
|
+
for (const [name, config] of Object.entries(modes)) {
|
|
135
|
+
if (config.default === true) {
|
|
136
|
+
return name;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseAgent(value: unknown): "claude" | "codex" {
|
|
143
|
+
if (!value) return "claude";
|
|
144
|
+
const normalized = String(value).toLowerCase();
|
|
145
|
+
if (normalized === "claude" || normalized === "codex") {
|
|
146
|
+
return normalized;
|
|
147
|
+
}
|
|
148
|
+
console.error(`\n❌ Invalid agent: ${value}. Use "claude" or "codex".`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parsePermissions(value: unknown): string[] {
|
|
153
|
+
const defaultPerms = ["Read", "Write", "Edit"];
|
|
154
|
+
if (!value) return defaultPerms;
|
|
155
|
+
if (Array.isArray(value)) return value.map(String);
|
|
156
|
+
return defaultPerms;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function printHelp() {
|
|
160
|
+
console.log(`
|
|
161
|
+
doc-sync: AI-powered documentation sync
|
|
162
|
+
|
|
163
|
+
Usage:
|
|
164
|
+
bun sync [options]
|
|
165
|
+
bun sync --mode <name> [options]
|
|
166
|
+
bun sync --code <path> --docs <path> [options]
|
|
167
|
+
|
|
168
|
+
Options:
|
|
169
|
+
-m, --mode Mode preset to use (from config file)
|
|
170
|
+
-c, --code-repo Path to code repository
|
|
171
|
+
-d, --docs-repo Path to documentation repository
|
|
172
|
+
-b, --branch Feature branch to analyze (default: current)
|
|
173
|
+
--base Base branch to diff against (default: main)
|
|
174
|
+
--config Path to YAML config file (default: doc-syncer.config.yml)
|
|
175
|
+
--dry-run Preview without running the agent
|
|
176
|
+
--agent AI agent to run (claude | codex)
|
|
177
|
+
-h, --help Show this help
|
|
178
|
+
|
|
179
|
+
Examples:
|
|
180
|
+
bun sync # Use default mode from config
|
|
181
|
+
bun sync --mode esign # Use specific mode
|
|
182
|
+
bun sync --mode esign --dry-run # Preview mode
|
|
183
|
+
bun sync --code ~/dev/app --docs ~/dev/app-docs
|
|
184
|
+
`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ============ GIT HELPERS ============
|
|
188
|
+
|
|
189
|
+
async function gitDiff(repoPath: string, base: string, branch: string): Promise<string> {
|
|
190
|
+
try {
|
|
191
|
+
const result = await $`git -C ${repoPath} diff ${base}...${branch}`.text();
|
|
192
|
+
return result;
|
|
193
|
+
} catch {
|
|
194
|
+
// Fallback: diff against base directly
|
|
195
|
+
const result = await $`git -C ${repoPath} diff ${base}`.text();
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function gitCurrentBranch(repoPath: string): Promise<string> {
|
|
201
|
+
const result = await $`git -C ${repoPath} branch --show-current`.text();
|
|
202
|
+
return result.trim();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function gitChangedFiles(repoPath: string, base: string, branch: string): Promise<string[]> {
|
|
206
|
+
try {
|
|
207
|
+
const result = await $`git -C ${repoPath} diff --name-only ${base}...${branch}`.text();
|
|
208
|
+
return result.trim().split("\n").filter(Boolean);
|
|
209
|
+
} catch {
|
|
210
|
+
const result = await $`git -C ${repoPath} diff --name-only ${base}`.text();
|
|
211
|
+
return result.trim().split("\n").filter(Boolean);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function checkIncomingChanges(repoPath: string): Promise<boolean> {
|
|
216
|
+
try {
|
|
217
|
+
// Fetch latest changes
|
|
218
|
+
await $`git -C ${repoPath} fetch --quiet`.quiet();
|
|
219
|
+
|
|
220
|
+
// Get current branch
|
|
221
|
+
const branch = await gitCurrentBranch(repoPath);
|
|
222
|
+
|
|
223
|
+
// Check if remote tracking branch exists
|
|
224
|
+
const hasRemote = await $`git -C ${repoPath} rev-parse --verify origin/${branch}`.quiet().nothrow();
|
|
225
|
+
if (hasRemote.exitCode !== 0) {
|
|
226
|
+
return false; // No remote branch, no incoming changes
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Count incoming commits
|
|
230
|
+
const result = await $`git -C ${repoPath} rev-list HEAD..origin/${branch} --count`.text();
|
|
231
|
+
const count = parseInt(result.trim(), 10);
|
|
232
|
+
return count > 0;
|
|
233
|
+
} catch {
|
|
234
|
+
return false; // On any error, assume no incoming changes
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function promptUser(question: string, options: string[]): Promise<string> {
|
|
239
|
+
console.log(`\n${question}`);
|
|
240
|
+
options.forEach((opt, i) => console.log(` ${i + 1}. ${opt}`));
|
|
241
|
+
|
|
242
|
+
const readline = require("readline").createInterface({
|
|
243
|
+
input: process.stdin,
|
|
244
|
+
output: process.stdout,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return new Promise((resolve) => {
|
|
248
|
+
const askQuestion = () => {
|
|
249
|
+
readline.question("\nYour choice (1-2): ", (answer: string) => {
|
|
250
|
+
const num = parseInt(answer.trim(), 10);
|
|
251
|
+
if (num >= 1 && num <= options.length) {
|
|
252
|
+
readline.close();
|
|
253
|
+
resolve(options[num - 1]);
|
|
254
|
+
} else {
|
|
255
|
+
process.stdout.write(`Invalid choice. Please enter 1-${options.length}.`);
|
|
256
|
+
askQuestion();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
};
|
|
260
|
+
askQuestion();
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ============ AGENT ============
|
|
265
|
+
|
|
266
|
+
async function runAgent(prompt: string, cwd: string, agent: Config["agent"], permissions: string[]): Promise<string> {
|
|
267
|
+
let command: string[];
|
|
268
|
+
let useStdin = false;
|
|
269
|
+
|
|
270
|
+
if (agent === "claude") {
|
|
271
|
+
command = ["claude", "--print"];
|
|
272
|
+
for (const perm of permissions) {
|
|
273
|
+
command.push("--allowedTools", perm);
|
|
274
|
+
}
|
|
275
|
+
useStdin = true; // Claude accepts prompt via stdin
|
|
276
|
+
} else {
|
|
277
|
+
command = ["codex", "exec", prompt];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const proc = Bun.spawn(command, {
|
|
281
|
+
cwd,
|
|
282
|
+
stdin: useStdin ? "pipe" : undefined,
|
|
283
|
+
stdout: "pipe",
|
|
284
|
+
stderr: "pipe",
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Send prompt via stdin for claude
|
|
288
|
+
if (useStdin && proc.stdin) {
|
|
289
|
+
proc.stdin.write(prompt);
|
|
290
|
+
proc.stdin.end();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const output = await new Response(proc.stdout).text();
|
|
294
|
+
const exitCode = await proc.exited;
|
|
295
|
+
|
|
296
|
+
if (exitCode !== 0) {
|
|
297
|
+
const stderr = await new Response(proc.stderr).text();
|
|
298
|
+
throw new Error(`Agent exited with code ${exitCode}: ${stderr}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return output;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ============ MAIN ============
|
|
305
|
+
|
|
306
|
+
async function main() {
|
|
307
|
+
const config = await loadConfig();
|
|
308
|
+
const agentLabel = config.agent === "claude" ? "Claude Code" : "Codex";
|
|
309
|
+
|
|
310
|
+
console.log(`
|
|
311
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
312
|
+
║ doc-sync ║
|
|
313
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
314
|
+
`);
|
|
315
|
+
if (config.mode) {
|
|
316
|
+
console.log(`📋 Mode: ${config.mode}`);
|
|
317
|
+
}
|
|
318
|
+
console.log(`📁 Code: ${config.codeRepo}`);
|
|
319
|
+
console.log(`📄 Docs: ${config.docsRepo}`);
|
|
320
|
+
console.log(`🎯 Base: ${config.baseBranch}`);
|
|
321
|
+
console.log(`🏃 Dry run: ${config.dryRun ? "yes" : "no"}`);
|
|
322
|
+
console.log(`🤖 Agent: ${agentLabel}`);
|
|
323
|
+
console.log(`🔑 Tools: ${config.permissions.join(", ")}`);
|
|
324
|
+
|
|
325
|
+
// Validate paths
|
|
326
|
+
const codeExists = await Bun.file(`${config.codeRepo}/.git/HEAD`).exists();
|
|
327
|
+
const docsExists = await Bun.file(`${config.docsRepo}/.git/HEAD`).exists();
|
|
328
|
+
|
|
329
|
+
if (!codeExists) {
|
|
330
|
+
console.error(`\n❌ Code repo not found: ${config.codeRepo}`);
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
if (!docsExists) {
|
|
334
|
+
console.error(`\n❌ Docs repo not found: ${config.docsRepo}`);
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Check for incoming changes in docs repo
|
|
339
|
+
console.log("\n🔍 Checking for incoming changes in docs repo...");
|
|
340
|
+
const hasIncoming = await checkIncomingChanges(config.docsRepo);
|
|
341
|
+
|
|
342
|
+
if (hasIncoming) {
|
|
343
|
+
console.log("⚠️ Warning: Docs repo has incoming changes from remote!\n");
|
|
344
|
+
const choice = await promptUser(
|
|
345
|
+
"What would you like to do?",
|
|
346
|
+
["Ignore and proceed anyway", "Stop and pull latest changes first"]
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
if (choice === "Stop and pull latest changes first") {
|
|
350
|
+
console.log("\n❌ Sync stopped.");
|
|
351
|
+
console.log(`\nPlease pull the latest changes first:`);
|
|
352
|
+
console.log(` cd ${config.docsRepo}`);
|
|
353
|
+
console.log(` git pull`);
|
|
354
|
+
console.log(`\nThen run doc-syncer again.`);
|
|
355
|
+
process.exit(0);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
console.log("\n⚠️ Proceeding with local changes...\n");
|
|
359
|
+
} else {
|
|
360
|
+
console.log("✅ Docs repo is up to date\n");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Get branch
|
|
364
|
+
const branch = config.featureBranch || await gitCurrentBranch(config.codeRepo);
|
|
365
|
+
console.log(`🌿 Branch: ${branch}\n`);
|
|
366
|
+
|
|
367
|
+
// Get diff
|
|
368
|
+
console.log("📊 Getting diff...");
|
|
369
|
+
const diff = await gitDiff(config.codeRepo, config.baseBranch, branch);
|
|
370
|
+
|
|
371
|
+
if (!diff.trim()) {
|
|
372
|
+
console.log("✅ No changes found. Nothing to document.");
|
|
373
|
+
process.exit(0);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const changedFiles = await gitChangedFiles(config.codeRepo, config.baseBranch, branch);
|
|
377
|
+
console.log(` ${changedFiles.length} files changed\n`);
|
|
378
|
+
|
|
379
|
+
// Build prompt
|
|
380
|
+
const prompt = buildPrompt(config, branch, diff, changedFiles);
|
|
381
|
+
|
|
382
|
+
if (config.dryRun) {
|
|
383
|
+
console.log("─".repeat(60));
|
|
384
|
+
console.log("DRY RUN — Would send this prompt to the agent:\n");
|
|
385
|
+
console.log(prompt.slice(0, 2000));
|
|
386
|
+
if (prompt.length > 2000) console.log(`\n... [${prompt.length} chars total]`);
|
|
387
|
+
console.log("─".repeat(60));
|
|
388
|
+
console.log("\nRun without --dry-run to execute.");
|
|
389
|
+
process.exit(0);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Run agent
|
|
393
|
+
console.log(`🤖 Running ${agentLabel}...`);
|
|
394
|
+
|
|
395
|
+
const startTime = Date.now();
|
|
396
|
+
let timerInterval: Timer | null = null;
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
// Start live timer
|
|
400
|
+
timerInterval = setInterval(() => {
|
|
401
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
402
|
+
const minutes = Math.floor(elapsed / 60);
|
|
403
|
+
const seconds = elapsed % 60;
|
|
404
|
+
const timeStr = minutes > 0
|
|
405
|
+
? `${minutes}m ${seconds}s`
|
|
406
|
+
: `${seconds}s`;
|
|
407
|
+
process.stdout.write(`\r⏱️ Running... ${timeStr}`);
|
|
408
|
+
}, 1000);
|
|
409
|
+
|
|
410
|
+
const result = await runAgent(prompt, config.docsRepo, config.agent, config.permissions);
|
|
411
|
+
|
|
412
|
+
// Clear timer
|
|
413
|
+
if (timerInterval) clearInterval(timerInterval);
|
|
414
|
+
const totalTime = Math.floor((Date.now() - startTime) / 1000);
|
|
415
|
+
const minutes = Math.floor(totalTime / 60);
|
|
416
|
+
const seconds = totalTime % 60;
|
|
417
|
+
const timeStr = minutes > 0
|
|
418
|
+
? `${minutes}m ${seconds}s`
|
|
419
|
+
: `${seconds}s`;
|
|
420
|
+
|
|
421
|
+
process.stdout.write(`\r✅ Completed in ${timeStr}\n\n`);
|
|
422
|
+
console.log("─".repeat(60));
|
|
423
|
+
console.log(result);
|
|
424
|
+
console.log("─".repeat(60));
|
|
425
|
+
console.log("\n✅ Done. Review changes:");
|
|
426
|
+
console.log(` cd ${config.docsRepo}`);
|
|
427
|
+
console.log(" git diff");
|
|
428
|
+
} catch (err) {
|
|
429
|
+
if (timerInterval) clearInterval(timerInterval);
|
|
430
|
+
console.error(`\n\n❌ ${(err as Error).message}`);
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function buildPrompt(
|
|
436
|
+
config: Config,
|
|
437
|
+
branch: string,
|
|
438
|
+
diff: string,
|
|
439
|
+
changedFiles: string[]
|
|
440
|
+
): string {
|
|
441
|
+
const maxDiff = 80000;
|
|
442
|
+
const truncated = diff.length > maxDiff
|
|
443
|
+
? diff.slice(0, maxDiff) + `\n\n... [truncated, ${diff.length} chars total]`
|
|
444
|
+
: diff;
|
|
445
|
+
|
|
446
|
+
return `You are a documentation expert. Update the docs in this repository based on code changes.
|
|
447
|
+
|
|
448
|
+
## Context
|
|
449
|
+
- Code repo: ${config.codeRepo}
|
|
450
|
+
- Docs repo: ${config.docsRepo} (you are here)
|
|
451
|
+
- Branch: ${branch}
|
|
452
|
+
- Base: ${config.baseBranch}
|
|
453
|
+
|
|
454
|
+
## Changed Files
|
|
455
|
+
${changedFiles.map(f => `- ${f}`).join("\n")}
|
|
456
|
+
|
|
457
|
+
## Diff
|
|
458
|
+
\`\`\`diff
|
|
459
|
+
${truncated}
|
|
460
|
+
\`\`\`
|
|
461
|
+
|
|
462
|
+
## Do the minimum necessary to update the docs!
|
|
463
|
+
|
|
464
|
+
## Task
|
|
465
|
+
1. Explore this docs repo — understand structure and style
|
|
466
|
+
2. Analyze the code changes — what was added/changed/removed
|
|
467
|
+
3. Update relevant documentation — match existing style
|
|
468
|
+
4. Report what you changed
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
You have full user's permissions to write into DOCs directory
|
|
472
|
+
Only update docs affected by the changes. Preserve formatting. If nothing needs updating, say so.`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
main().catch(console.error);
|