client-handover 1.2.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 +226 -0
- package/cli.js +185 -0
- package/generator.js +343 -0
- package/package.json +36 -0
- package/prompts.js +185 -0
- package/scanner.js +250 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sabrkei
|
|
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,226 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="images/claude-client-handover.png" width="120" alt="claude-client-handover" />
|
|
3
|
+
<h1>claude-client-handover</h1>
|
|
4
|
+
<p><em>Scott AK (sabrkei)</em></p>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
AI-powered handover document generator for frontend developers handing off websites to clients.
|
|
8
|
+
|
|
9
|
+
Run one command. Get a professional handover document in Markdown, plain text, and HTML — written for both your client and the next developer.
|
|
10
|
+
|
|
11
|
+
Uses the [Claude API](https://console.anthropic.com) (Anthropic) under the hood.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g client-handover
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### API key setup
|
|
22
|
+
|
|
23
|
+
**If you have Claude Code installed**, no setup needed — `client-handover` will automatically use your existing Claude credentials.
|
|
24
|
+
|
|
25
|
+
**Otherwise**, set your Anthropic API key as an environment variable:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# macOS / Linux
|
|
29
|
+
export ANTHROPIC_API_KEY=your_key_here
|
|
30
|
+
|
|
31
|
+
# Windows (Command Prompt)
|
|
32
|
+
set ANTHROPIC_API_KEY=your_key_here
|
|
33
|
+
|
|
34
|
+
# Windows (PowerShell)
|
|
35
|
+
$env:ANTHROPIC_API_KEY="your_key_here"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Get a free API key at [console.anthropic.com](https://console.anthropic.com)
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
handover handover
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This generates a full handover document with placeholder content in an `output/` folder in your current directory.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
| Command | Description |
|
|
55
|
+
|---------|-------------|
|
|
56
|
+
| `all` | Every section as separate files in one folder |
|
|
57
|
+
| `handover` | Full handover document (all sections combined) |
|
|
58
|
+
| `setup` | Project setup & dependencies |
|
|
59
|
+
| `deploy` | Deployment & hosting info |
|
|
60
|
+
| `credentials` | Logins & API keys template |
|
|
61
|
+
| `license` | Licensing & attribution |
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Usage
|
|
66
|
+
|
|
67
|
+
### With your own project notes
|
|
68
|
+
|
|
69
|
+
Create a plain text file describing your project:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
project-info.txt
|
|
73
|
+
----------------
|
|
74
|
+
Project: Acme Corp website
|
|
75
|
+
Stack: Vue 3, Vite, Netlify
|
|
76
|
+
Repo: https://github.com/you/acme
|
|
77
|
+
Live URL: https://acmecorp.com
|
|
78
|
+
Hosting: Netlify (free tier)
|
|
79
|
+
Domain: Namecheap, auto-renews Jan 2026
|
|
80
|
+
CMS: Netlify CMS
|
|
81
|
+
Analytics: Google Analytics 4
|
|
82
|
+
APIs: Mailchimp (newsletter), Stripe (payments)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Then run any command with that file as input:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Every section as separate files in one folder
|
|
89
|
+
handover all project-info.txt acme-client
|
|
90
|
+
|
|
91
|
+
# Full combined doc
|
|
92
|
+
handover handover project-info.txt
|
|
93
|
+
|
|
94
|
+
# Full combined doc with a custom output filename
|
|
95
|
+
handover handover project-info.txt acme-handover
|
|
96
|
+
|
|
97
|
+
# Individual sections
|
|
98
|
+
handover setup project-info.txt
|
|
99
|
+
handover deploy project-info.txt
|
|
100
|
+
handover credentials project-info.txt
|
|
101
|
+
handover license project-info.txt
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The more detail you put in your project-info file, the more accurate and useful the output will be.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Output
|
|
109
|
+
|
|
110
|
+
All commands generate three files per section inside an `output/` folder.
|
|
111
|
+
|
|
112
|
+
**Single command** (e.g. `handover`):
|
|
113
|
+
```
|
|
114
|
+
output/
|
|
115
|
+
├── handover.md ← Paste into Notion, GitHub, or a README
|
|
116
|
+
├── handover.txt ← Clean plain text for email or printing
|
|
117
|
+
└── handover.html ← Styled HTML ready to send directly to a client
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**`all` command** — every section in its own subfolder:
|
|
121
|
+
```
|
|
122
|
+
output/acme-client/
|
|
123
|
+
├── handover.md / .txt / .html
|
|
124
|
+
├── setup.md / .txt / .html
|
|
125
|
+
├── deploy.md / .txt / .html
|
|
126
|
+
├── credentials.md / .txt / .html
|
|
127
|
+
└── license.md / .txt / .html
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
| Format | Best for |
|
|
131
|
+
|--------|----------|
|
|
132
|
+
| `.md` | Notion, GitHub, linear docs |
|
|
133
|
+
| `.txt` | Email attachments, printing |
|
|
134
|
+
| `.html`| Sending directly to a client |
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Use as a library
|
|
139
|
+
|
|
140
|
+
You can also import the prompt builders and doc generator directly into your own project:
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
import { handover, setup, generateDoc } from 'client-handover'
|
|
144
|
+
|
|
145
|
+
// Build a prompt from your project info string
|
|
146
|
+
const prompt = handover('Vue 3 project hosted on Vercel, domain on Cloudflare...')
|
|
147
|
+
|
|
148
|
+
// Generate and save the document
|
|
149
|
+
await generateDoc(prompt, 'my-client', './docs')
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Available exports
|
|
153
|
+
|
|
154
|
+
| Export | Type | Description |
|
|
155
|
+
|--------|------|-------------|
|
|
156
|
+
| `generateDoc(prompt, name, dir)` | async function | Calls Claude API and writes `.md`, `.txt`, `.html` |
|
|
157
|
+
| `handover(projectInfo)` | function | Prompt builder for full handover doc |
|
|
158
|
+
| `setup(projectInfo)` | function | Prompt builder for setup section |
|
|
159
|
+
| `deploy(projectInfo)` | function | Prompt builder for deployment section |
|
|
160
|
+
| `credentials(projectInfo)` | function | Prompt builder for credentials section |
|
|
161
|
+
| `license(projectInfo)` | function | Prompt builder for licensing section |
|
|
162
|
+
|
|
163
|
+
All prompt builders accept an optional `projectInfo` string. If omitted, Claude generates placeholder content.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Requirements
|
|
168
|
+
|
|
169
|
+
- Node.js 18+
|
|
170
|
+
- One of the following:
|
|
171
|
+
- [Claude Code](https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code) installed (zero config — credentials detected automatically)
|
|
172
|
+
- Or an Anthropic API key — [get one free at console.anthropic.com](https://console.anthropic.com)
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## How it works
|
|
177
|
+
|
|
178
|
+
1. You provide a plain text description of your project (or nothing, for placeholder output)
|
|
179
|
+
2. The CLI builds a structured prompt for Claude
|
|
180
|
+
3. Claude generates a professional, dual-audience document (plain English for clients, technical detail for developers)
|
|
181
|
+
4. The output is saved as `.md`, `.txt`, and `.html`
|
|
182
|
+
|
|
183
|
+
Each document section is written for **two audiences**:
|
|
184
|
+
- **The client** — plain English, reassuring tone, no jargon
|
|
185
|
+
- **The next developer** — precise technical detail, commands, file paths
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Project structure
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
client-handover/
|
|
193
|
+
├── cli.js # CLI entry point
|
|
194
|
+
├── index.js # Library exports
|
|
195
|
+
├── generator.js # Claude API call + file output
|
|
196
|
+
├── handover.js # Full handover prompt builder
|
|
197
|
+
├── setup.js # Setup section prompt builder
|
|
198
|
+
├── deploy.js # Deployment section prompt builder
|
|
199
|
+
├── credentials.js # Credentials section prompt builder
|
|
200
|
+
└── license.js # Licensing section prompt builder
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Changelog
|
|
206
|
+
|
|
207
|
+
### 1.0.0
|
|
208
|
+
- Initial release
|
|
209
|
+
- Single command: `handover /create`
|
|
210
|
+
- Generates two documents per run — technical handover (for developers) and client handover (plain English)
|
|
211
|
+
- Auto-scans project files: package.json, config files, env variable keys, deploy configs, folder structure, CSS colours
|
|
212
|
+
- First-run setup prompts for API key, name, company, email, and phone
|
|
213
|
+
- Outputs `.md`, `.txt`, and `.docx` formats
|
|
214
|
+
- Auto-detects Claude Code credentials — no API key setup needed if you have Claude Code installed
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Contributing
|
|
219
|
+
|
|
220
|
+
Pull requests are welcome. For major changes, open an issue first.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT
|
package/cli.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { generateDoc, resolveApiKey, saveApiKey } from './generator.js'
|
|
4
|
+
import { technicalHandoverPrompt, nonTechnicalHandoverPrompt } from './prompts.js'
|
|
5
|
+
import { scanProject } from './scanner.js'
|
|
6
|
+
import chalk from 'chalk'
|
|
7
|
+
import readline from 'readline'
|
|
8
|
+
import fs from 'fs'
|
|
9
|
+
import path from 'path'
|
|
10
|
+
import os from 'os'
|
|
11
|
+
|
|
12
|
+
const { version } = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf-8'))
|
|
13
|
+
|
|
14
|
+
function normalizeCommand(cmd) {
|
|
15
|
+
return path.basename(cmd).replace(/^\/+/, '')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function printBanner() {
|
|
19
|
+
const t = chalk.bold.white
|
|
20
|
+
console.log()
|
|
21
|
+
console.log(' ' + t('█ █ ███ █ █ ████ ███ █ █ █████ ████ '))
|
|
22
|
+
console.log(' ' + t('█ █ █ █ ██ █ █ █ █ █ █ █ █ █ █'))
|
|
23
|
+
console.log(' ' + t('█████ █████ █ █ █ █ █ █ █ █ █ ████ ████ '))
|
|
24
|
+
console.log(' ' + t('█ █ █ █ █ ██ █ █ █ █ █ █ █ █ █ '))
|
|
25
|
+
console.log(' ' + t('█ █ █ █ █ █ ████ ███ █ █████ █ █ '))
|
|
26
|
+
console.log()
|
|
27
|
+
console.log(chalk.dim(' Website handover documents for clients & developers') + chalk.dim(' · v' + version))
|
|
28
|
+
console.log()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function printHelp() {
|
|
32
|
+
printBanner()
|
|
33
|
+
console.log(chalk.dim(' Usage:'))
|
|
34
|
+
console.log(' handover /create Generate technical & non-technical handover documents')
|
|
35
|
+
console.log(' handover key <key> Save your Anthropic API key\n')
|
|
36
|
+
console.log(chalk.dim(' Example:'))
|
|
37
|
+
console.log(' cd my-client-project')
|
|
38
|
+
console.log(' handover /create\n')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ask(rl, question) {
|
|
42
|
+
return new Promise(resolve => rl.question(question, answer => resolve(answer.trim())))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async function runSetup() {
|
|
47
|
+
const configDir = path.join(os.homedir(), '.handover')
|
|
48
|
+
const configPath = path.join(configDir, 'config.json')
|
|
49
|
+
|
|
50
|
+
let config = {}
|
|
51
|
+
try {
|
|
52
|
+
if (fs.existsSync(configPath)) config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
53
|
+
} catch {}
|
|
54
|
+
|
|
55
|
+
const hasApiKey = !!resolveApiKey(config)
|
|
56
|
+
const hasDeveloperInfo = !!config.developerName
|
|
57
|
+
|
|
58
|
+
if (hasApiKey && hasDeveloperInfo) {
|
|
59
|
+
return {
|
|
60
|
+
name: config.developerName,
|
|
61
|
+
company: config.developerCompany || '',
|
|
62
|
+
email: config.developerEmail || '',
|
|
63
|
+
phone: config.developerPhone || '',
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log(chalk.bold('\n──────────────────────────────────────────'))
|
|
68
|
+
console.log(chalk.bold(' client-handover — first time setup'))
|
|
69
|
+
console.log(chalk.bold('──────────────────────────────────────────\n'))
|
|
70
|
+
|
|
71
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
72
|
+
|
|
73
|
+
if (!hasApiKey) {
|
|
74
|
+
console.log(' To generate documents you need an Anthropic API key.')
|
|
75
|
+
console.log(chalk.dim(' Get one free at: https://console.anthropic.com\n'))
|
|
76
|
+
const key = await ask(rl, ' Enter your Anthropic API key (or press Enter to skip): ')
|
|
77
|
+
if (key) {
|
|
78
|
+
config.apiKey = key
|
|
79
|
+
console.log(chalk.green(' ✓ API key saved.\n'))
|
|
80
|
+
} else {
|
|
81
|
+
console.log(chalk.dim(' Skipped. Run "handover key <your-api-key>" at any time.\n'))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!hasDeveloperInfo) {
|
|
86
|
+
console.log(' Your details will appear in all generated handover documents.\n')
|
|
87
|
+
const name = await ask(rl, ' Your full name: ')
|
|
88
|
+
if (name) config.developerName = name
|
|
89
|
+
|
|
90
|
+
const company = await ask(rl, ' Your company name (press Enter if not applicable): ')
|
|
91
|
+
if (company) config.developerCompany = company
|
|
92
|
+
|
|
93
|
+
const email = await ask(rl, ' Your email address: ')
|
|
94
|
+
if (email) config.developerEmail = email
|
|
95
|
+
|
|
96
|
+
const phone = await ask(rl, ' Your phone number (optional, press Enter to skip): ')
|
|
97
|
+
if (phone) config.developerPhone = phone
|
|
98
|
+
|
|
99
|
+
console.log()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
rl.close()
|
|
103
|
+
|
|
104
|
+
if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true })
|
|
105
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8')
|
|
106
|
+
console.log(chalk.green(' ✓ Setup saved.\n'))
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
name: config.developerName || '',
|
|
110
|
+
company: config.developerCompany || '',
|
|
111
|
+
email: config.developerEmail || '',
|
|
112
|
+
phone: config.developerPhone || '',
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function runCreate() {
|
|
117
|
+
printBanner()
|
|
118
|
+
const developerInfo = await runSetup()
|
|
119
|
+
|
|
120
|
+
console.log(chalk.dim(' Scanning project files...'))
|
|
121
|
+
const projectInfo = scanProject(process.cwd())
|
|
122
|
+
|
|
123
|
+
const techDir = './technical-handover'
|
|
124
|
+
const clientDir = './handover'
|
|
125
|
+
|
|
126
|
+
console.log(chalk.bold('\n Generating Technical Handover Document...'))
|
|
127
|
+
const techPrompt = technicalHandoverPrompt(projectInfo, developerInfo)
|
|
128
|
+
await generateDoc(techPrompt, 'technical-handover', techDir)
|
|
129
|
+
|
|
130
|
+
console.log(chalk.bold('\n Generating Client Handover Document...'))
|
|
131
|
+
const nonTechPrompt = nonTechnicalHandoverPrompt(projectInfo, developerInfo)
|
|
132
|
+
await generateDoc(nonTechPrompt, 'handover', clientDir)
|
|
133
|
+
|
|
134
|
+
console.log(chalk.green.bold('\n ✅ Handover documents created successfully!\n'))
|
|
135
|
+
console.log(` ${chalk.cyan('./technical-handover/')}`)
|
|
136
|
+
console.log(` technical-handover.md`)
|
|
137
|
+
console.log(` technical-handover.txt`)
|
|
138
|
+
console.log(` technical-handover.docx`)
|
|
139
|
+
console.log()
|
|
140
|
+
console.log(` ${chalk.cyan('./handover/')}`)
|
|
141
|
+
console.log(` handover.md`)
|
|
142
|
+
console.log(` handover.txt`)
|
|
143
|
+
console.log(` handover.docx`)
|
|
144
|
+
console.log()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function main() {
|
|
148
|
+
const [,, rawCommand, secondArg] = process.argv
|
|
149
|
+
|
|
150
|
+
if (!rawCommand || rawCommand === '--help' || rawCommand === '-h') {
|
|
151
|
+
printHelp()
|
|
152
|
+
process.exit(0)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const command = normalizeCommand(rawCommand)
|
|
156
|
+
|
|
157
|
+
if (command === 'key') {
|
|
158
|
+
if (!secondArg) {
|
|
159
|
+
console.error(chalk.red('\n ❌ Usage: handover key <your-api-key>\n'))
|
|
160
|
+
process.exit(1)
|
|
161
|
+
}
|
|
162
|
+
saveApiKey(secondArg)
|
|
163
|
+
console.log(chalk.green('\n ✅ API key saved. Run "handover /create" to get started.\n'))
|
|
164
|
+
process.exit(0)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (command !== 'create') {
|
|
168
|
+
console.error(chalk.red(`\n ❌ Unknown command: ${rawCommand}\n`))
|
|
169
|
+
printHelp()
|
|
170
|
+
process.exit(1)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
await runCreate()
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if (err.status === 401) {
|
|
177
|
+
console.error(chalk.red('\n ❌ Authentication failed. Run "handover key <your-api-key>" to set your API key.\n'))
|
|
178
|
+
} else {
|
|
179
|
+
console.error(chalk.red(`\n ❌ Error: ${err.message}\n`))
|
|
180
|
+
}
|
|
181
|
+
process.exit(1)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
main()
|
package/generator.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk'
|
|
2
|
+
import {
|
|
3
|
+
Document, Packer, Paragraph, TextRun, HeadingLevel,
|
|
4
|
+
AlignmentType, BorderStyle, TableRow, TableCell, Table,
|
|
5
|
+
WidthType, ShadingType
|
|
6
|
+
} from 'docx'
|
|
7
|
+
import fs from 'fs'
|
|
8
|
+
import path from 'path'
|
|
9
|
+
import os from 'os'
|
|
10
|
+
|
|
11
|
+
export function resolveApiKey(config = null) {
|
|
12
|
+
if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY
|
|
13
|
+
|
|
14
|
+
const cfg = config || (() => {
|
|
15
|
+
try {
|
|
16
|
+
const p = path.join(os.homedir(), '.handover', 'config.json')
|
|
17
|
+
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
|
18
|
+
} catch {}
|
|
19
|
+
return {}
|
|
20
|
+
})()
|
|
21
|
+
|
|
22
|
+
if (cfg?.apiKey) return cfg.apiKey
|
|
23
|
+
|
|
24
|
+
const credentialsPath = path.join(os.homedir(), '.claude', '.credentials.json')
|
|
25
|
+
try {
|
|
26
|
+
if (fs.existsSync(credentialsPath)) {
|
|
27
|
+
const creds = JSON.parse(fs.readFileSync(credentialsPath, 'utf-8'))
|
|
28
|
+
const token = creds?.claudeAiOauth?.accessToken
|
|
29
|
+
const expiresAt = creds?.claudeAiOauth?.expiresAt
|
|
30
|
+
if (token && (!expiresAt || expiresAt > Date.now())) return token
|
|
31
|
+
}
|
|
32
|
+
} catch {}
|
|
33
|
+
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function saveApiKey(key) {
|
|
38
|
+
const configDir = path.join(os.homedir(), '.handover')
|
|
39
|
+
const configPath = path.join(configDir, 'config.json')
|
|
40
|
+
if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true })
|
|
41
|
+
let config = {}
|
|
42
|
+
try { config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) } catch {}
|
|
43
|
+
config.apiKey = key
|
|
44
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const apiKey = resolveApiKey()
|
|
48
|
+
if (!apiKey) {
|
|
49
|
+
console.error('\n ❌ No API key found. Run "handover key <your-api-key>" or install Claude Code.\n')
|
|
50
|
+
process.exit(1)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const client = new Anthropic({ apiKey })
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Inline text parser — splits a line into TextRun segments handling **bold**,
|
|
57
|
+
// *italic*, `code`, and [link text](url) → just the text
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
function parseInline(text) {
|
|
60
|
+
const runs = []
|
|
61
|
+
// Strip markdown links to plain text
|
|
62
|
+
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
63
|
+
|
|
64
|
+
const pattern = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g
|
|
65
|
+
let lastIndex = 0
|
|
66
|
+
let match
|
|
67
|
+
|
|
68
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
69
|
+
if (match.index > lastIndex) {
|
|
70
|
+
runs.push(new TextRun({ text: text.slice(lastIndex, match.index) }))
|
|
71
|
+
}
|
|
72
|
+
if (match[2] !== undefined) {
|
|
73
|
+
runs.push(new TextRun({ text: match[2], bold: true }))
|
|
74
|
+
} else if (match[3] !== undefined) {
|
|
75
|
+
runs.push(new TextRun({ text: match[3], italics: true }))
|
|
76
|
+
} else if (match[4] !== undefined) {
|
|
77
|
+
runs.push(new TextRun({
|
|
78
|
+
text: match[4],
|
|
79
|
+
font: 'Courier New',
|
|
80
|
+
size: 18,
|
|
81
|
+
shading: { type: ShadingType.CLEAR, fill: 'F4F4F4' },
|
|
82
|
+
}))
|
|
83
|
+
}
|
|
84
|
+
lastIndex = match.index + match[0].length
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (lastIndex < text.length) {
|
|
88
|
+
runs.push(new TextRun({ text: text.slice(lastIndex) }))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return runs.length ? runs : [new TextRun({ text })]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Convert markdown string to an array of docx Paragraph/Table objects
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
function markdownToDocxChildren(markdown) {
|
|
98
|
+
const children = []
|
|
99
|
+
const lines = markdown.split('\n')
|
|
100
|
+
let i = 0
|
|
101
|
+
|
|
102
|
+
while (i < lines.length) {
|
|
103
|
+
const line = lines[i]
|
|
104
|
+
|
|
105
|
+
// Fenced code block
|
|
106
|
+
if (line.trim().startsWith('```')) {
|
|
107
|
+
const codeLines = []
|
|
108
|
+
i++
|
|
109
|
+
while (i < lines.length && !lines[i].trim().startsWith('```')) {
|
|
110
|
+
codeLines.push(lines[i])
|
|
111
|
+
i++
|
|
112
|
+
}
|
|
113
|
+
for (const codeLine of codeLines) {
|
|
114
|
+
children.push(new Paragraph({
|
|
115
|
+
children: [new TextRun({ text: codeLine || ' ', font: 'Courier New', size: 18 })],
|
|
116
|
+
shading: { type: ShadingType.CLEAR, fill: 'F4F4F4' },
|
|
117
|
+
spacing: { before: 0, after: 0 },
|
|
118
|
+
indent: { left: 360 },
|
|
119
|
+
}))
|
|
120
|
+
}
|
|
121
|
+
// Add small gap after code block
|
|
122
|
+
children.push(new Paragraph({ text: '' }))
|
|
123
|
+
i++
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Horizontal rule
|
|
128
|
+
if (/^---+$/.test(line.trim())) {
|
|
129
|
+
children.push(new Paragraph({
|
|
130
|
+
text: '',
|
|
131
|
+
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'CCCCCC' } },
|
|
132
|
+
spacing: { before: 200, after: 200 },
|
|
133
|
+
}))
|
|
134
|
+
i++
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Headings
|
|
139
|
+
const h1 = line.match(/^# (.+)/)
|
|
140
|
+
const h2 = line.match(/^## (.+)/)
|
|
141
|
+
const h3 = line.match(/^### (.+)/)
|
|
142
|
+
const h4 = line.match(/^#{4,6} (.+)/)
|
|
143
|
+
|
|
144
|
+
if (h1) {
|
|
145
|
+
children.push(new Paragraph({
|
|
146
|
+
children: parseInline(h1[1]),
|
|
147
|
+
heading: HeadingLevel.HEADING_1,
|
|
148
|
+
spacing: { before: 400, after: 160 },
|
|
149
|
+
}))
|
|
150
|
+
i++; continue
|
|
151
|
+
}
|
|
152
|
+
if (h2) {
|
|
153
|
+
children.push(new Paragraph({
|
|
154
|
+
children: parseInline(h2[1]),
|
|
155
|
+
heading: HeadingLevel.HEADING_2,
|
|
156
|
+
spacing: { before: 320, after: 120 },
|
|
157
|
+
}))
|
|
158
|
+
i++; continue
|
|
159
|
+
}
|
|
160
|
+
if (h3) {
|
|
161
|
+
children.push(new Paragraph({
|
|
162
|
+
children: parseInline(h3[1]),
|
|
163
|
+
heading: HeadingLevel.HEADING_3,
|
|
164
|
+
spacing: { before: 240, after: 80 },
|
|
165
|
+
}))
|
|
166
|
+
i++; continue
|
|
167
|
+
}
|
|
168
|
+
if (h4) {
|
|
169
|
+
children.push(new Paragraph({
|
|
170
|
+
children: [new TextRun({ text: h4[1], bold: true })],
|
|
171
|
+
spacing: { before: 160, after: 80 },
|
|
172
|
+
}))
|
|
173
|
+
i++; continue
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Checkbox list items - [ ] or - [x]
|
|
177
|
+
const checkbox = line.match(/^(\s*)- \[(x| )\] (.+)/i)
|
|
178
|
+
if (checkbox) {
|
|
179
|
+
const checked = checkbox[2].toLowerCase() === 'x'
|
|
180
|
+
children.push(new Paragraph({
|
|
181
|
+
children: [
|
|
182
|
+
new TextRun({ text: checked ? '☑ ' : '☐ ' }),
|
|
183
|
+
...parseInline(checkbox[3]),
|
|
184
|
+
],
|
|
185
|
+
indent: { left: 360 },
|
|
186
|
+
spacing: { before: 40, after: 40 },
|
|
187
|
+
}))
|
|
188
|
+
i++; continue
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Bullet list items - or *
|
|
192
|
+
const bullet = line.match(/^(\s*)[-*+] (.+)/)
|
|
193
|
+
if (bullet) {
|
|
194
|
+
const indent = bullet[1].length > 0 ? 720 : 360
|
|
195
|
+
children.push(new Paragraph({
|
|
196
|
+
children: [new TextRun({ text: '• ' }), ...parseInline(bullet[2])],
|
|
197
|
+
indent: { left: indent },
|
|
198
|
+
spacing: { before: 40, after: 40 },
|
|
199
|
+
}))
|
|
200
|
+
i++; continue
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Numbered list items
|
|
204
|
+
const numbered = line.match(/^\d+\. (.+)/)
|
|
205
|
+
if (numbered) {
|
|
206
|
+
children.push(new Paragraph({
|
|
207
|
+
children: parseInline(numbered[1]),
|
|
208
|
+
numbering: { reference: 'default-numbering', level: 0 },
|
|
209
|
+
indent: { left: 360 },
|
|
210
|
+
spacing: { before: 40, after: 40 },
|
|
211
|
+
}))
|
|
212
|
+
i++; continue
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Bold-only line (often used as a label/key line like **Key:** value)
|
|
216
|
+
// Handled by parseInline already — fall through to normal paragraph
|
|
217
|
+
|
|
218
|
+
// Empty line
|
|
219
|
+
if (line.trim() === '') {
|
|
220
|
+
children.push(new Paragraph({ text: '', spacing: { before: 0, after: 80 } }))
|
|
221
|
+
i++; continue
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Normal paragraph
|
|
225
|
+
children.push(new Paragraph({
|
|
226
|
+
children: parseInline(line),
|
|
227
|
+
spacing: { before: 0, after: 80 },
|
|
228
|
+
}))
|
|
229
|
+
i++
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return children
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Build a docx Document from markdown
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
function buildDocx(markdown) {
|
|
239
|
+
const children = markdownToDocxChildren(markdown)
|
|
240
|
+
|
|
241
|
+
return new Document({
|
|
242
|
+
numbering: {
|
|
243
|
+
config: [{
|
|
244
|
+
reference: 'default-numbering',
|
|
245
|
+
levels: [{
|
|
246
|
+
level: 0,
|
|
247
|
+
format: 'decimal',
|
|
248
|
+
text: '%1.',
|
|
249
|
+
alignment: AlignmentType.LEFT,
|
|
250
|
+
style: { paragraph: { indent: { left: 360, hanging: 260 } } },
|
|
251
|
+
}],
|
|
252
|
+
}],
|
|
253
|
+
},
|
|
254
|
+
styles: {
|
|
255
|
+
default: {
|
|
256
|
+
document: {
|
|
257
|
+
run: { font: 'Calibri', size: 22 },
|
|
258
|
+
paragraph: { spacing: { line: 276 } },
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
paragraphStyles: [
|
|
262
|
+
{
|
|
263
|
+
id: 'Heading1',
|
|
264
|
+
name: 'Heading 1',
|
|
265
|
+
basedOn: 'Normal',
|
|
266
|
+
next: 'Normal',
|
|
267
|
+
run: { bold: true, size: 36, color: '1a1a1a' },
|
|
268
|
+
paragraph: { spacing: { before: 400, after: 160 } },
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: 'Heading2',
|
|
272
|
+
name: 'Heading 2',
|
|
273
|
+
basedOn: 'Normal',
|
|
274
|
+
next: 'Normal',
|
|
275
|
+
run: { bold: true, size: 28, color: '2c2c2c' },
|
|
276
|
+
paragraph: {
|
|
277
|
+
spacing: { before: 320, after: 120 },
|
|
278
|
+
border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: 'E0E0E0' } },
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
id: 'Heading3',
|
|
283
|
+
name: 'Heading 3',
|
|
284
|
+
basedOn: 'Normal',
|
|
285
|
+
next: 'Normal',
|
|
286
|
+
run: { bold: true, size: 24, color: '444444' },
|
|
287
|
+
paragraph: { spacing: { before: 240, after: 80 } },
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
sections: [{ properties: {}, children }],
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Main export
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
export async function generateDoc(prompt, outputName = 'handover', outputDir = './output') {
|
|
299
|
+
console.log('⏳ Generating document with Claude...\n')
|
|
300
|
+
|
|
301
|
+
const message = await client.messages.create({
|
|
302
|
+
model: 'claude-sonnet-4-20250514',
|
|
303
|
+
max_tokens: 4096,
|
|
304
|
+
messages: [{ role: 'user', content: prompt }]
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
const markdown = message.content[0].text
|
|
308
|
+
|
|
309
|
+
if (!fs.existsSync(outputDir)) {
|
|
310
|
+
fs.mkdirSync(outputDir, { recursive: true })
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const basePath = path.join(outputDir, outputName)
|
|
314
|
+
|
|
315
|
+
// Save as Markdown
|
|
316
|
+
const mdPath = `${basePath}.md`
|
|
317
|
+
fs.writeFileSync(mdPath, markdown, 'utf-8')
|
|
318
|
+
console.log(`✅ Markdown saved: ${mdPath}`)
|
|
319
|
+
|
|
320
|
+
// Save as plain text
|
|
321
|
+
const plainText = markdown
|
|
322
|
+
.replace(/#{1,6}\s+/g, '')
|
|
323
|
+
.replace(/\*\*(.*?)\*\*/g, '$1')
|
|
324
|
+
.replace(/\*(.*?)\*/g, '$1')
|
|
325
|
+
.replace(/`{1,3}[^`]*`{1,3}/g, m => m.replace(/`/g, ''))
|
|
326
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
327
|
+
.replace(/[-*+] /g, '• ')
|
|
328
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
329
|
+
.trim()
|
|
330
|
+
|
|
331
|
+
const txtPath = `${basePath}.txt`
|
|
332
|
+
fs.writeFileSync(txtPath, plainText, 'utf-8')
|
|
333
|
+
console.log(`✅ Plain text saved: ${txtPath}`)
|
|
334
|
+
|
|
335
|
+
// Save as Word document
|
|
336
|
+
const doc = buildDocx(markdown)
|
|
337
|
+
const docxBuffer = await Packer.toBuffer(doc)
|
|
338
|
+
const docxPath = `${basePath}.docx`
|
|
339
|
+
fs.writeFileSync(docxPath, docxBuffer)
|
|
340
|
+
console.log(`✅ Word doc saved: ${docxPath}`)
|
|
341
|
+
|
|
342
|
+
return { markdown, plainText }
|
|
343
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "client-handover",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "AI-powered handover document generator for frontend developers handing off client websites",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"handover": "cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"cli.js",
|
|
11
|
+
"generator.js",
|
|
12
|
+
"prompts.js",
|
|
13
|
+
"scanner.js",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"handover",
|
|
19
|
+
"frontend",
|
|
20
|
+
"client",
|
|
21
|
+
"documentation",
|
|
22
|
+
"developer",
|
|
23
|
+
"claude",
|
|
24
|
+
"ai"
|
|
25
|
+
],
|
|
26
|
+
"author": "sabrkei",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@anthropic-ai/sdk": "^0.39.0",
|
|
33
|
+
"chalk": "^5.3.0",
|
|
34
|
+
"docx": "^8.5.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/prompts.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
export function technicalHandoverPrompt(projectInfo, developerInfo) {
|
|
2
|
+
const { name, company, email, phone } = developerInfo
|
|
3
|
+
const date = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })
|
|
4
|
+
|
|
5
|
+
return `
|
|
6
|
+
You are an expert technical writer generating a TECHNICAL HANDOVER DOCUMENT for a developer taking over a client website project.
|
|
7
|
+
|
|
8
|
+
This document is written for a DEVELOPER (future maintainer) — be precise, thorough, and technically complete. They need to be able to pick this project up cold with no prior knowledge.
|
|
9
|
+
|
|
10
|
+
Developer who built this project:
|
|
11
|
+
- Name: ${name || '[Developer Name]'}
|
|
12
|
+
${company ? `- Company: ${company}` : ''}
|
|
13
|
+
- Email: ${email || '[Developer Email]'}
|
|
14
|
+
${phone ? `- Phone: ${phone}` : ''}
|
|
15
|
+
- Handover date: ${date}
|
|
16
|
+
|
|
17
|
+
The following context has been automatically scanned from the project files (package.json, config files, folder structure, environment variable keys, deploy configs, README, CSS colors, etc.). Use this to generate a document specific to THIS project. Do not invent or use generic placeholders — if a specific detail is not in the scanned data, note it as "[to be confirmed]".
|
|
18
|
+
|
|
19
|
+
${projectInfo || '[No project data available]'}
|
|
20
|
+
|
|
21
|
+
Generate a complete technical handover document with ALL of the following sections:
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
# [Project Name] — Technical Handover Document
|
|
26
|
+
|
|
27
|
+
**Prepared by:** ${name || '[Developer Name]'}${company ? ` · ${company}` : ''}${email ? ` · ${email}` : ''}${phone ? ` · ${phone}` : ''}
|
|
28
|
+
**Handover Date:** ${date}
|
|
29
|
+
**Project URL:** [Live URL — to be confirmed]
|
|
30
|
+
**Repository:** [Repo URL — to be confirmed]
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 1. Tech Stack
|
|
35
|
+
- List every technology, framework, library, and tool used
|
|
36
|
+
- Include versions where known
|
|
37
|
+
- Briefly explain the role of each in the project
|
|
38
|
+
|
|
39
|
+
## 2. Project Structure
|
|
40
|
+
- Full folder/file structure with explanations of what each directory contains
|
|
41
|
+
- Location of key config files and what they control
|
|
42
|
+
- Entry points and routing overview
|
|
43
|
+
|
|
44
|
+
## 3. Local Development Setup
|
|
45
|
+
- Prerequisites (Node version, package manager, etc.)
|
|
46
|
+
- Step-by-step install and run instructions using exact terminal commands in code blocks
|
|
47
|
+
- All environment variables required (keys only, no values) — what each one does
|
|
48
|
+
|
|
49
|
+
## 4. Build & Deployment
|
|
50
|
+
- Build command and output directory
|
|
51
|
+
- Hosting provider and deployment method
|
|
52
|
+
- CI/CD pipeline if applicable
|
|
53
|
+
- Domain, DNS, and SSL details
|
|
54
|
+
- How to roll back a broken deployment
|
|
55
|
+
|
|
56
|
+
## 5. Third-Party Integrations & Services
|
|
57
|
+
- Every external service, API, or plugin used
|
|
58
|
+
- What each one does and where it is configured
|
|
59
|
+
- Which accounts own these services
|
|
60
|
+
|
|
61
|
+
## 6. Credentials & Access
|
|
62
|
+
- Table of all accounts needed (service, URL, account owner, how to request access)
|
|
63
|
+
- Environment variable names and their purpose
|
|
64
|
+
- Access transfer checklist
|
|
65
|
+
|
|
66
|
+
## 7. Known Issues & Technical Debt
|
|
67
|
+
- Any bugs, limitations, or workarounds in the current codebase
|
|
68
|
+
- TODO items or deferred improvements
|
|
69
|
+
- Performance or SEO considerations
|
|
70
|
+
|
|
71
|
+
## 8. Maintenance Guide
|
|
72
|
+
- How to update npm dependencies safely
|
|
73
|
+
- What to check after deploying changes
|
|
74
|
+
- Renewal dates for domain, hosting, or licences if known
|
|
75
|
+
|
|
76
|
+
## 9. Developer Handover Checklist
|
|
77
|
+
- [ ] Repository access transferred or shared
|
|
78
|
+
- [ ] All environment variables documented
|
|
79
|
+
- [ ] Hosting and domain access transferred
|
|
80
|
+
- [ ] Third-party service accounts handed over
|
|
81
|
+
- [ ] Local dev environment tested and documented
|
|
82
|
+
- [ ] Live site tested across browsers and devices
|
|
83
|
+
- [ ] Analytics verified working
|
|
84
|
+
- [ ] README updated
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
Format the entire document in clean, well-structured Markdown.
|
|
89
|
+
Use code blocks for all terminal commands and code snippets.
|
|
90
|
+
Use tables where appropriate (especially for credentials and dependencies).
|
|
91
|
+
Be thorough and precise — this document is the sole reference for the incoming developer.
|
|
92
|
+
`.trim()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function nonTechnicalHandoverPrompt(projectInfo, developerInfo) {
|
|
96
|
+
const { name, company, email, phone } = developerInfo
|
|
97
|
+
const date = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })
|
|
98
|
+
|
|
99
|
+
return `
|
|
100
|
+
You are an expert writer generating a NON-TECHNICAL HANDOVER DOCUMENT for a CLIENT receiving their completed website.
|
|
101
|
+
|
|
102
|
+
This document is written for the BUSINESS OWNER or CLIENT — use plain English, a warm and reassuring tone, and absolutely no technical jargon. The client should feel confident and informed about what they own.
|
|
103
|
+
|
|
104
|
+
Developer contact details:
|
|
105
|
+
- Name: ${name || '[Developer Name]'}
|
|
106
|
+
${company ? `- Company: ${company}` : ''}
|
|
107
|
+
- Email: ${email || '[Developer Email]'}
|
|
108
|
+
${phone ? `- Phone: ${phone}` : ''}
|
|
109
|
+
- Handover date: ${date}
|
|
110
|
+
|
|
111
|
+
The following context has been automatically scanned from the project files. Use this to generate a document specific to THIS project. Focus on what the client needs to know — not how it was built. If a specific detail is not available, note it as "[to be confirmed]".
|
|
112
|
+
|
|
113
|
+
${projectInfo || '[No project data available]'}
|
|
114
|
+
|
|
115
|
+
Generate a complete non-technical handover document with ALL of the following sections:
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
# [Project Name] — Your Website Handover Guide
|
|
120
|
+
|
|
121
|
+
**Prepared by:** ${name || '[Developer Name]'}${company ? ` · ${company}` : ''}${email ? ` · ${email}` : ''}${phone ? ` · ${phone}` : ''}
|
|
122
|
+
**Date:** ${date}
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 1. About Your Website
|
|
127
|
+
- What your website does and who it is for (1–2 paragraphs, plain English)
|
|
128
|
+
- The main pages or sections and what each one is for
|
|
129
|
+
- Any special features the site has (contact forms, booking, e-commerce, blog, etc.)
|
|
130
|
+
|
|
131
|
+
## 2. Your Website's Look & Feel
|
|
132
|
+
- The overall design style and visual identity
|
|
133
|
+
- Colours used on the site (mention specific colours by name and hex code if detected in the project)
|
|
134
|
+
- Fonts used
|
|
135
|
+
- Any branding guidelines to keep in mind when making future updates
|
|
136
|
+
|
|
137
|
+
## 3. What You Own
|
|
138
|
+
- A plain-English summary of everything handed over: the website, domain, hosting, code, and content
|
|
139
|
+
- Who owns what and what that means for you
|
|
140
|
+
|
|
141
|
+
## 4. Logging Into Your Website
|
|
142
|
+
- How to access and log in to any admin area or CMS (plain steps, no jargon)
|
|
143
|
+
- What you can safely update yourself
|
|
144
|
+
- What you should NOT change without speaking to a developer first
|
|
145
|
+
|
|
146
|
+
## 5. How to Update Your Content
|
|
147
|
+
- Step-by-step instructions for common tasks (e.g. updating text, adding images, publishing a blog post)
|
|
148
|
+
- Keep instructions simple and numbered
|
|
149
|
+
- Include a note about backing up before making changes
|
|
150
|
+
|
|
151
|
+
## 6. Your Accounts & Passwords
|
|
152
|
+
- List of all accounts the client now owns (hosting, domain, CMS, email, analytics, etc.)
|
|
153
|
+
- Reminder to store passwords securely and change them after handover
|
|
154
|
+
- Note: actual passwords should be shared separately and securely — not in this document
|
|
155
|
+
|
|
156
|
+
## 7. Keeping Your Website Healthy
|
|
157
|
+
- What needs renewing and approximately when (domain name, hosting plan, SSL certificate, any paid plugins)
|
|
158
|
+
- How to tell if something on the site is broken
|
|
159
|
+
- Simple monthly and yearly maintenance checklist
|
|
160
|
+
|
|
161
|
+
## 8. Getting Help
|
|
162
|
+
- When and how to contact your developer: ${name || '[Developer Name]'}${company ? ` (${company})` : ''}${email ? `, ${email}` : ''}${phone ? `, ${phone}` : ''}
|
|
163
|
+
- What kinds of changes require a developer
|
|
164
|
+
- Recommended process for requesting future updates
|
|
165
|
+
|
|
166
|
+
## 9. Handover Sign-Off
|
|
167
|
+
### Developer confirms:
|
|
168
|
+
- [ ] All accounts and login details have been transferred
|
|
169
|
+
- [ ] The live site has been reviewed and approved
|
|
170
|
+
- [ ] This document has been discussed with the client
|
|
171
|
+
|
|
172
|
+
### Client acknowledges:
|
|
173
|
+
- [ ] I have received all login credentials securely
|
|
174
|
+
- [ ] I understand how to update my website content
|
|
175
|
+
- [ ] I know what I should not change without developer help
|
|
176
|
+
- [ ] I know how to contact my developer for support
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
Write the entire document in plain, friendly English. Use short sentences and simple words.
|
|
181
|
+
Avoid ALL technical jargon — if a technical term must be used, explain it in brackets immediately after.
|
|
182
|
+
Use numbered lists for any step-by-step instructions.
|
|
183
|
+
The tone should be warm, professional, and reassuring — the client should feel proud of their new website.
|
|
184
|
+
`.trim()
|
|
185
|
+
}
|
package/scanner.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
|
|
4
|
+
const CONFIG_FILES = [
|
|
5
|
+
'vite.config.js', 'vite.config.ts',
|
|
6
|
+
'next.config.js', 'next.config.ts', 'next.config.mjs',
|
|
7
|
+
'nuxt.config.js', 'nuxt.config.ts',
|
|
8
|
+
'astro.config.js', 'astro.config.ts', 'astro.config.mjs',
|
|
9
|
+
'svelte.config.js',
|
|
10
|
+
'remix.config.js',
|
|
11
|
+
'tailwind.config.js', 'tailwind.config.ts',
|
|
12
|
+
'postcss.config.js',
|
|
13
|
+
'netlify.toml',
|
|
14
|
+
'vercel.json',
|
|
15
|
+
'render.yaml',
|
|
16
|
+
'railway.json',
|
|
17
|
+
'.htaccess',
|
|
18
|
+
'Dockerfile',
|
|
19
|
+
'docker-compose.yml',
|
|
20
|
+
'firebase.json',
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
const DEPLOY_WORKFLOW_DIR = '.github/workflows'
|
|
24
|
+
|
|
25
|
+
function readFileSafe(filePath) {
|
|
26
|
+
try {
|
|
27
|
+
return fs.readFileSync(filePath, 'utf-8')
|
|
28
|
+
} catch {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getEnvKeys(filePath) {
|
|
34
|
+
const content = readFileSafe(filePath)
|
|
35
|
+
if (!content) return []
|
|
36
|
+
return content
|
|
37
|
+
.split('\n')
|
|
38
|
+
.map(l => l.trim())
|
|
39
|
+
.filter(l => l && !l.startsWith('#') && l.includes('='))
|
|
40
|
+
.map(l => l.split('=')[0].trim())
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getFolderStructure(dir, depth = 0, maxDepth = 2) {
|
|
44
|
+
if (depth > maxDepth) return []
|
|
45
|
+
const ignore = new Set(['node_modules', '.git', '.next', '.nuxt', 'dist', 'build', '.cache', 'coverage', '.turbo'])
|
|
46
|
+
let entries = []
|
|
47
|
+
try {
|
|
48
|
+
const items = fs.readdirSync(dir, { withFileTypes: true })
|
|
49
|
+
for (const item of items) {
|
|
50
|
+
if (ignore.has(item.name) || item.name.startsWith('.')) continue
|
|
51
|
+
const indent = ' '.repeat(depth)
|
|
52
|
+
if (item.isDirectory()) {
|
|
53
|
+
entries.push(`${indent}${item.name}/`)
|
|
54
|
+
entries.push(...getFolderStructure(path.join(dir, item.name), depth + 1, maxDepth))
|
|
55
|
+
} else {
|
|
56
|
+
entries.push(`${indent}${item.name}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
return entries
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function detectFramework(deps = {}, devDeps = {}) {
|
|
64
|
+
const all = { ...deps, ...devDeps }
|
|
65
|
+
if (all['next']) return 'Next.js'
|
|
66
|
+
if (all['nuxt'] || all['nuxt3']) return 'Nuxt'
|
|
67
|
+
if (all['@astrojs/core'] || all['astro']) return 'Astro'
|
|
68
|
+
if (all['@sveltejs/kit']) return 'SvelteKit'
|
|
69
|
+
if (all['svelte']) return 'Svelte'
|
|
70
|
+
if (all['@remix-run/react']) return 'Remix'
|
|
71
|
+
if (all['gatsby']) return 'Gatsby'
|
|
72
|
+
if (all['react']) return 'React'
|
|
73
|
+
if (all['vue']) return 'Vue'
|
|
74
|
+
if (all['angular']) return 'Angular'
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function scanProject(dir = process.cwd()) {
|
|
79
|
+
const sections = []
|
|
80
|
+
|
|
81
|
+
// --- package.json ---
|
|
82
|
+
const pkgPath = path.join(dir, 'package.json')
|
|
83
|
+
let pkg = null
|
|
84
|
+
if (fs.existsSync(pkgPath)) {
|
|
85
|
+
try {
|
|
86
|
+
pkg = JSON.parse(readFileSafe(pkgPath))
|
|
87
|
+
const framework = detectFramework(pkg.dependencies, pkg.devDependencies)
|
|
88
|
+
sections.push(`## package.json`)
|
|
89
|
+
sections.push(`Name: ${pkg.name || 'unknown'}`)
|
|
90
|
+
if (pkg.description) sections.push(`Description: ${pkg.description}`)
|
|
91
|
+
if (pkg.version) sections.push(`Version: ${pkg.version}`)
|
|
92
|
+
if (framework) sections.push(`Detected framework: ${framework}`)
|
|
93
|
+
if (pkg.engines?.node) sections.push(`Node requirement: ${pkg.engines.node}`)
|
|
94
|
+
|
|
95
|
+
if (pkg.scripts && Object.keys(pkg.scripts).length) {
|
|
96
|
+
sections.push(`\nScripts:`)
|
|
97
|
+
for (const [k, v] of Object.entries(pkg.scripts)) {
|
|
98
|
+
sections.push(` ${k}: ${v}`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const deps = Object.keys(pkg.dependencies || {})
|
|
103
|
+
const devDeps = Object.keys(pkg.devDependencies || {})
|
|
104
|
+
if (deps.length) sections.push(`\nDependencies: ${deps.join(', ')}`)
|
|
105
|
+
if (devDeps.length) sections.push(`Dev dependencies: ${devDeps.join(', ')}`)
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- Package manager ---
|
|
110
|
+
let pm = 'npm'
|
|
111
|
+
if (fs.existsSync(path.join(dir, 'yarn.lock'))) pm = 'yarn'
|
|
112
|
+
else if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) pm = 'pnpm'
|
|
113
|
+
else if (fs.existsSync(path.join(dir, 'bun.lockb'))) pm = 'bun'
|
|
114
|
+
sections.push(`\nPackage manager: ${pm}`)
|
|
115
|
+
|
|
116
|
+
// --- Environment variables ---
|
|
117
|
+
const envExampleKeys = getEnvKeys(path.join(dir, '.env.example'))
|
|
118
|
+
const envLocalKeys = getEnvKeys(path.join(dir, '.env.local'))
|
|
119
|
+
const envKeys = getEnvKeys(path.join(dir, '.env'))
|
|
120
|
+
const allEnvKeys = [...new Set([...envExampleKeys, ...envLocalKeys, ...envKeys])]
|
|
121
|
+
if (allEnvKeys.length) {
|
|
122
|
+
sections.push(`\n## Environment Variables (keys only)`)
|
|
123
|
+
allEnvKeys.forEach(k => sections.push(` ${k}`))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// --- Config files ---
|
|
127
|
+
for (const file of CONFIG_FILES) {
|
|
128
|
+
const filePath = path.join(dir, file)
|
|
129
|
+
if (fs.existsSync(filePath)) {
|
|
130
|
+
const content = readFileSafe(filePath)
|
|
131
|
+
if (content && content.length < 4000) {
|
|
132
|
+
sections.push(`\n## ${file}`)
|
|
133
|
+
sections.push('```')
|
|
134
|
+
sections.push(content.trim())
|
|
135
|
+
sections.push('```')
|
|
136
|
+
} else if (content) {
|
|
137
|
+
sections.push(`\n## ${file} (truncated)`)
|
|
138
|
+
sections.push('```')
|
|
139
|
+
sections.push(content.trim().slice(0, 4000) + '\n...')
|
|
140
|
+
sections.push('```')
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- GitHub Actions workflows ---
|
|
146
|
+
const workflowDir = path.join(dir, DEPLOY_WORKFLOW_DIR)
|
|
147
|
+
if (fs.existsSync(workflowDir)) {
|
|
148
|
+
try {
|
|
149
|
+
const files = fs.readdirSync(workflowDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'))
|
|
150
|
+
for (const file of files) {
|
|
151
|
+
const content = readFileSafe(path.join(workflowDir, file))
|
|
152
|
+
if (content) {
|
|
153
|
+
sections.push(`\n## .github/workflows/${file}`)
|
|
154
|
+
sections.push('```yaml')
|
|
155
|
+
sections.push(content.trim().slice(0, 3000))
|
|
156
|
+
sections.push('```')
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} catch {}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- README ---
|
|
163
|
+
const readmePath = path.join(dir, 'README.md')
|
|
164
|
+
if (fs.existsSync(readmePath)) {
|
|
165
|
+
const content = readFileSafe(readmePath)
|
|
166
|
+
if (content) {
|
|
167
|
+
sections.push(`\n## README.md`)
|
|
168
|
+
sections.push(content.trim().slice(0, 3000))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- License ---
|
|
173
|
+
for (const f of ['LICENSE', 'LICENSE.md', 'LICENSE.txt']) {
|
|
174
|
+
const p = path.join(dir, f)
|
|
175
|
+
if (fs.existsSync(p)) {
|
|
176
|
+
const content = readFileSafe(p)
|
|
177
|
+
if (content) {
|
|
178
|
+
sections.push(`\n## ${f}`)
|
|
179
|
+
sections.push(content.trim().slice(0, 500))
|
|
180
|
+
}
|
|
181
|
+
break
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- CSS color detection ---
|
|
186
|
+
const cssColors = extractColors(dir)
|
|
187
|
+
if (cssColors.length) {
|
|
188
|
+
sections.push(`\n## Detected colours (from CSS/config files)`)
|
|
189
|
+
cssColors.forEach(c => sections.push(` ${c}`))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- Folder structure ---
|
|
193
|
+
const structure = getFolderStructure(dir)
|
|
194
|
+
if (structure.length) {
|
|
195
|
+
sections.push(`\n## Project folder structure`)
|
|
196
|
+
sections.push(structure.join('\n'))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return sections.join('\n')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function extractColors(dir) {
|
|
203
|
+
const colors = new Set()
|
|
204
|
+
const hexPattern = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g
|
|
205
|
+
const cssVarColorPattern = /(--[\w-]*color[\w-]*|--[\w-]*bg[\w-]*|--[\w-]*primary[\w-]*|--[\w-]*secondary[\w-]*):\s*([^;}\n]+)/gi
|
|
206
|
+
const cssExtensions = ['.css', '.scss', '.sass', '.less']
|
|
207
|
+
|
|
208
|
+
function scanFile(filePath) {
|
|
209
|
+
const content = readFileSafe(filePath)
|
|
210
|
+
if (!content) return
|
|
211
|
+
let match
|
|
212
|
+
while ((match = hexPattern.exec(content)) !== null) {
|
|
213
|
+
colors.add(`#${match[1].toUpperCase()}`)
|
|
214
|
+
}
|
|
215
|
+
while ((match = cssVarColorPattern.exec(content)) !== null) {
|
|
216
|
+
colors.add(`${match[1].trim()}: ${match[2].trim()}`)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function walkDir(d, depth = 0) {
|
|
221
|
+
if (depth > 3) return
|
|
222
|
+
const ignore = new Set(['node_modules', '.git', '.next', '.nuxt', 'dist', 'build', '.cache'])
|
|
223
|
+
try {
|
|
224
|
+
const items = fs.readdirSync(d, { withFileTypes: true })
|
|
225
|
+
for (const item of items) {
|
|
226
|
+
if (ignore.has(item.name) || item.name.startsWith('.')) continue
|
|
227
|
+
const full = path.join(d, item.name)
|
|
228
|
+
if (item.isDirectory()) {
|
|
229
|
+
walkDir(full, depth + 1)
|
|
230
|
+
} else if (cssExtensions.includes(path.extname(item.name).toLowerCase())) {
|
|
231
|
+
scanFile(full)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} catch {}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
walkDir(dir)
|
|
238
|
+
|
|
239
|
+
// Also check tailwind config for color definitions
|
|
240
|
+
for (const twConfig of ['tailwind.config.js', 'tailwind.config.ts']) {
|
|
241
|
+
const content = readFileSafe(path.join(dir, twConfig))
|
|
242
|
+
if (!content) continue
|
|
243
|
+
let match
|
|
244
|
+
while ((match = hexPattern.exec(content)) !== null) {
|
|
245
|
+
colors.add(`#${match[1].toUpperCase()}`)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return [...colors].slice(0, 40) // cap at 40 to avoid flooding context
|
|
250
|
+
}
|