ekohacks 0.4.0 → 0.5.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/dist/cli.js +66 -2
- package/dist/infrastructure/claude.js +54 -0
- package/dist/logic/docs.js +132 -3
- package/package.json +10 -1
package/dist/cli.js
CHANGED
|
@@ -9,8 +9,9 @@ import { GhWrapper } from './infrastructure/gh.js';
|
|
|
9
9
|
import { GitWrapper } from './infrastructure/git.js';
|
|
10
10
|
import { NpmWrapper } from './infrastructure/npm.js';
|
|
11
11
|
import { ProcessRunner } from './infrastructure/process.js';
|
|
12
|
+
import { ClaudeWrapper } from './infrastructure/claude.js';
|
|
12
13
|
import { cut } from './logic/cut.js';
|
|
13
|
-
import { docsCheck, docsSync } from './logic/docs.js';
|
|
14
|
+
import { docsCheck, docsDraft, docsSync, draftBranchConflict, draftPrompts, openDraftPr, } from './logic/docs.js';
|
|
14
15
|
import { preflight } from './logic/preflight.js';
|
|
15
16
|
import { release } from './logic/release.js';
|
|
16
17
|
import { ship } from './logic/ship.js';
|
|
@@ -18,6 +19,7 @@ const USAGE = [
|
|
|
18
19
|
'usage: ekohacks release [preflight|cut|ship] <version> [--yes]',
|
|
19
20
|
' ekohacks docs check',
|
|
20
21
|
' ekohacks docs sync [--dry-run]',
|
|
22
|
+
' ekohacks docs draft [--yes]',
|
|
21
23
|
].join('\n');
|
|
22
24
|
const FLAGS = ['--yes', '--dry-run'];
|
|
23
25
|
const argv = process.argv.slice(2);
|
|
@@ -31,7 +33,7 @@ const printChecks = (checks) => {
|
|
|
31
33
|
};
|
|
32
34
|
if (command === 'docs') {
|
|
33
35
|
const subject = rest[0];
|
|
34
|
-
if (rest.length !== 1 || (subject !== 'check' && subject !== 'sync')) {
|
|
36
|
+
if (rest.length !== 1 || (subject !== 'check' && subject !== 'sync' && subject !== 'draft')) {
|
|
35
37
|
console.error(USAGE);
|
|
36
38
|
process.exit(2);
|
|
37
39
|
}
|
|
@@ -67,6 +69,68 @@ if (command === 'docs') {
|
|
|
67
69
|
}
|
|
68
70
|
process.exit(0);
|
|
69
71
|
}
|
|
72
|
+
if (subject === 'draft') {
|
|
73
|
+
if (process.env.ANTHROPIC_API_KEY === undefined || process.env.ANTHROPIC_API_KEY === '') {
|
|
74
|
+
console.error('stopped: docs draft needs ANTHROPIC_API_KEY');
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const prompts = draftPrompts({ pkg: manifest.name, exports: manifest.exports, files });
|
|
78
|
+
if (prompts.length === 0) {
|
|
79
|
+
console.log(' nothing to draft: no page carries a draft block');
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
// The PR branch is checked before the model runs, not after: a repeat run stops here for
|
|
83
|
+
// free rather than spending on drafts it could never open. The same wrapper opens the PR.
|
|
84
|
+
const git = GitWrapper.create();
|
|
85
|
+
const conflict = await draftBranchConflict(git);
|
|
86
|
+
if (conflict !== undefined) {
|
|
87
|
+
console.error(`stopped: ${conflict}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const pages = `${prompts.length} page${prompts.length === 1 ? '' : 's'}`;
|
|
91
|
+
console.log(` ${pages} to draft, one model call each:`);
|
|
92
|
+
for (const { specifier } of prompts) {
|
|
93
|
+
console.log(` ${specifier}`);
|
|
94
|
+
}
|
|
95
|
+
if (!yes) {
|
|
96
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
97
|
+
const answer = await readline.question(` draft ${pages}? (y/n) `);
|
|
98
|
+
readline.close();
|
|
99
|
+
if (answer.trim().toLowerCase() !== 'y') {
|
|
100
|
+
console.error('stopped: draft not approved');
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
let claude;
|
|
105
|
+
try {
|
|
106
|
+
claude = await ClaudeWrapper.create();
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
console.error(`stopped: ${error.message}`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
const { edits } = await docsDraft({
|
|
113
|
+
pkg: manifest.name,
|
|
114
|
+
exports: manifest.exports,
|
|
115
|
+
files,
|
|
116
|
+
claude,
|
|
117
|
+
});
|
|
118
|
+
for (const edit of edits) {
|
|
119
|
+
writeFileSync(edit.path, edit.content);
|
|
120
|
+
console.log(` drafted ${edit.path}`);
|
|
121
|
+
}
|
|
122
|
+
const pr = await openDraftPr({
|
|
123
|
+
specifiers: prompts.map((prompt) => prompt.specifier),
|
|
124
|
+
git,
|
|
125
|
+
gh: GhWrapper.create(),
|
|
126
|
+
});
|
|
127
|
+
if ('stopped' in pr) {
|
|
128
|
+
console.error(`stopped: ${pr.stopped}`);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
console.log(` opened pr #${pr.number}`);
|
|
132
|
+
process.exit(0);
|
|
133
|
+
}
|
|
70
134
|
const report = await docsCheck({
|
|
71
135
|
pkg: manifest.name,
|
|
72
136
|
exports: manifest.exports,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const MODEL = 'claude-opus-4-8';
|
|
2
|
+
const MAX_TOKENS = 16000;
|
|
3
|
+
// Wraps the one model call the drafting policy needs: a prompt in, the text out. Real and null
|
|
4
|
+
// share every line above the bottom layer — create() reaches the SDK through a dynamic import
|
|
5
|
+
// so the other commands never pay for a client nobody asked for, and stops with a named reason
|
|
6
|
+
// when the optional peer dependency is absent; createNull() answers configured text in order,
|
|
7
|
+
// imports nothing, and never opens a socket. Both record every prompt through complete(), the
|
|
8
|
+
// way NpmWrapper records every bump, so a test can pin what the policy asked without a spy.
|
|
9
|
+
export class ClaudeWrapper {
|
|
10
|
+
static async create({ apiKey = process.env.ANTHROPIC_API_KEY, } = {}) {
|
|
11
|
+
const sdk = await import('@anthropic-ai/sdk').catch(() => undefined);
|
|
12
|
+
if (sdk === undefined) {
|
|
13
|
+
throw new Error('docs draft needs @anthropic-ai/sdk — npm i -g @anthropic-ai/sdk');
|
|
14
|
+
}
|
|
15
|
+
const client = new sdk.default({ apiKey });
|
|
16
|
+
return new ClaudeWrapper(async (prompt) => {
|
|
17
|
+
const message = await client.messages.create({
|
|
18
|
+
model: MODEL,
|
|
19
|
+
max_tokens: MAX_TOKENS,
|
|
20
|
+
thinking: { type: 'adaptive' },
|
|
21
|
+
messages: [{ role: 'user', content: prompt }],
|
|
22
|
+
});
|
|
23
|
+
return message.content
|
|
24
|
+
.filter((block) => block.type === 'text')
|
|
25
|
+
.map((block) => block.text)
|
|
26
|
+
.join('');
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
static createNull({ responses = [] } = {}) {
|
|
30
|
+
let round = 0;
|
|
31
|
+
return new ClaudeWrapper((_prompt) => {
|
|
32
|
+
const index = Math.min(round, responses.length - 1);
|
|
33
|
+
round += 1;
|
|
34
|
+
return Promise.resolve(responses[index] ?? '');
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
runComplete;
|
|
38
|
+
promptTrackers = [];
|
|
39
|
+
constructor(runComplete) {
|
|
40
|
+
this.runComplete = runComplete;
|
|
41
|
+
}
|
|
42
|
+
trackPrompts() {
|
|
43
|
+
const tracker = [];
|
|
44
|
+
this.promptTrackers.push(tracker);
|
|
45
|
+
return { data: tracker };
|
|
46
|
+
}
|
|
47
|
+
async complete(prompt) {
|
|
48
|
+
const text = await this.runComplete(prompt);
|
|
49
|
+
for (const tracker of this.promptTrackers) {
|
|
50
|
+
tracker.push(prompt);
|
|
51
|
+
}
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/dist/logic/docs.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { ClaudeWrapper } from '../infrastructure/claude.js';
|
|
2
|
+
import { GhWrapper } from '../infrastructure/gh.js';
|
|
3
|
+
import { GitWrapper } from '../infrastructure/git.js';
|
|
1
4
|
import { ProcessRunner } from '../infrastructure/process.js';
|
|
5
|
+
const DRAFT_BRANCH = 'docs/draft';
|
|
2
6
|
const DOCS_BUILD_COMMAND = 'npm run docs:build';
|
|
3
7
|
const OPEN_MARKER = '<!-- ekohacks:entry-points -->';
|
|
4
8
|
const CLOSE_MARKER = '<!-- /ekohacks:entry-points -->';
|
|
9
|
+
const DRAFT_OPEN = '<!-- ekohacks:draft -->';
|
|
10
|
+
const DRAFT_CLOSE = '<!-- /ekohacks:draft -->';
|
|
5
11
|
// The public entry points an exports map declares, as the specifiers a consumer imports:
|
|
6
12
|
// "." is the bare package name, "./react" is pkg/react. A package with no exports map —
|
|
7
13
|
// or a conditions-only one, with no "." keys — has a single entry point: itself.
|
|
@@ -155,15 +161,22 @@ const syncedBlock = (region, pkg, entries) => {
|
|
|
155
161
|
return kept.join('\n');
|
|
156
162
|
};
|
|
157
163
|
// A page the tool can write without knowing anything it does not know: the import line it can
|
|
158
|
-
// derive, and a TODO everywhere prose belongs. The
|
|
159
|
-
// the
|
|
164
|
+
// derive, and a TODO everywhere prose belongs. The example and "what works today" live inside a
|
|
165
|
+
// draft block the drafting tool owns, the seam `docs draft` writes into; the heading above and
|
|
166
|
+
// the sidebar TODO below stay outside it, so a draft never touches them. The sidebar is code the
|
|
167
|
+
// sync will not touch, so the line a human has to add is spelled out rather than written.
|
|
168
|
+
// The page a specifier documents, derived from the specifier alone: ekolite/config → config.
|
|
169
|
+
// stubFor writes it and draftPrompts reads it back, so it lives in one place rather than two.
|
|
170
|
+
const pageNameFor = (specifier) => specifier.split('/').at(-1) ?? specifier;
|
|
160
171
|
const stubFor = (specifier) => {
|
|
161
|
-
const name = specifier
|
|
172
|
+
const name = pageNameFor(specifier);
|
|
162
173
|
return {
|
|
163
174
|
path: `docs/${name}.md`,
|
|
164
175
|
content: [
|
|
165
176
|
`# ${specifier}`,
|
|
166
177
|
'',
|
|
178
|
+
DRAFT_OPEN,
|
|
179
|
+
'',
|
|
167
180
|
'```ts',
|
|
168
181
|
importLineFor(specifier),
|
|
169
182
|
'',
|
|
@@ -174,6 +187,8 @@ const stubFor = (specifier) => {
|
|
|
174
187
|
'',
|
|
175
188
|
'- TODO: what a reader can rely on today, not what is planned.',
|
|
176
189
|
'',
|
|
190
|
+
DRAFT_CLOSE,
|
|
191
|
+
'',
|
|
177
192
|
'<!-- TODO: add this page to the sidebar in docs/.vitepress/config.mts:',
|
|
178
193
|
` { text: '${name}', link: '/${name}' } -->`,
|
|
179
194
|
'',
|
|
@@ -231,3 +246,117 @@ export const docsSync = ({ pkg, exports, files, }) => {
|
|
|
231
246
|
}
|
|
232
247
|
return { edits };
|
|
233
248
|
};
|
|
249
|
+
// The exports map value a specifier resolves to, so the prompt can show the entry point's real
|
|
250
|
+
// shape. The key is the mirror of entryPointsFrom: pkg is ".", pkg/config is "./config".
|
|
251
|
+
const exportsEntryFor = (pkg, exports, specifier) => {
|
|
252
|
+
if (typeof exports !== 'object' || exports === null) {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
const key = specifier === pkg ? '.' : `./${specifier.slice(pkg.length + 1)}`;
|
|
256
|
+
return exports[key];
|
|
257
|
+
};
|
|
258
|
+
// One prompt per undrafted page — a page carrying a draft block whose entry point the exports
|
|
259
|
+
// map still declares. The repo is the style guide: the prompt quotes two existing pages verbatim
|
|
260
|
+
// rather than describing a voice, so the draft tracks the docs instead of a copy of them. Pure —
|
|
261
|
+
// the caller runs the model and lands the result.
|
|
262
|
+
export const draftPrompts = ({ pkg, exports, files, }) => {
|
|
263
|
+
const scanned = files.filter((file) => !file.path.split('/').includes('.vitepress'));
|
|
264
|
+
const voice = scanned
|
|
265
|
+
.filter((file) => !file.content.includes(DRAFT_OPEN))
|
|
266
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
267
|
+
.slice(0, 2)
|
|
268
|
+
.map((page) => `--- ${page.path} ---\n${page.content}`)
|
|
269
|
+
.join('\n\n');
|
|
270
|
+
const prompts = [];
|
|
271
|
+
for (const specifier of entryPointsFrom(pkg, exports)) {
|
|
272
|
+
if (specifier === pkg) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const path = `docs/${pageNameFor(specifier)}.md`;
|
|
276
|
+
const page = scanned.find((file) => file.path === path);
|
|
277
|
+
if (page === undefined || !page.content.includes(DRAFT_OPEN)) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const prompt = [
|
|
281
|
+
`You are writing the documentation page for the \`${specifier}\` entry point of \`${pkg}\`.`,
|
|
282
|
+
'',
|
|
283
|
+
"Its entry in the package's exports map:",
|
|
284
|
+
'',
|
|
285
|
+
JSON.stringify(exportsEntryFor(pkg, exports, specifier), null, 2),
|
|
286
|
+
'',
|
|
287
|
+
'The page as it stands, a scaffold with a draft block to fill:',
|
|
288
|
+
'',
|
|
289
|
+
page.content,
|
|
290
|
+
'',
|
|
291
|
+
'Write only what belongs inside the draft block: one import-and-use example that runs, and a',
|
|
292
|
+
'short "what works today" list. Do not invent API the exports map does not name. Match the',
|
|
293
|
+
'voice, structure and formatting of these existing pages exactly:',
|
|
294
|
+
'',
|
|
295
|
+
voice,
|
|
296
|
+
].join('\n');
|
|
297
|
+
prompts.push({ specifier, path, prompt });
|
|
298
|
+
}
|
|
299
|
+
return prompts;
|
|
300
|
+
};
|
|
301
|
+
// The drafted prose replaces the contents of the draft block and nothing else — the heading
|
|
302
|
+
// above it and the sidebar TODO below it are outside the markers, so they survive. A page with
|
|
303
|
+
// no block is returned unchanged, which is how docsDraft leaves a human's prose alone.
|
|
304
|
+
const applyDraft = (content, prose) => {
|
|
305
|
+
const open = content.indexOf(DRAFT_OPEN);
|
|
306
|
+
if (open === -1) {
|
|
307
|
+
return content;
|
|
308
|
+
}
|
|
309
|
+
const afterOpen = content.slice(open + DRAFT_OPEN.length);
|
|
310
|
+
const close = afterOpen.indexOf(DRAFT_CLOSE);
|
|
311
|
+
if (close === -1) {
|
|
312
|
+
return content;
|
|
313
|
+
}
|
|
314
|
+
const before = content.slice(0, open);
|
|
315
|
+
const after = afterOpen.slice(close + DRAFT_CLOSE.length);
|
|
316
|
+
return `${before}${DRAFT_OPEN}\n\n${prose.trim()}\n\n${DRAFT_CLOSE}${after}`;
|
|
317
|
+
};
|
|
318
|
+
// The drafting policy: one model call per undrafted page, the returned prose landed in that
|
|
319
|
+
// page's block. Same shape as docsSync — the files whose content should change, each a whole
|
|
320
|
+
// new body — so the shell writes a draft exactly as it writes a sync. The model's output is
|
|
321
|
+
// whatever the wrapper answers; the policy only decides where it lands.
|
|
322
|
+
export const docsDraft = async ({ pkg, exports, files, claude, }) => {
|
|
323
|
+
const edits = [];
|
|
324
|
+
for (const { path, prompt } of draftPrompts({ pkg, exports, files })) {
|
|
325
|
+
const page = files.find((file) => file.path === path);
|
|
326
|
+
if (page === undefined) {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const prose = await claude.complete(prompt);
|
|
330
|
+
edits.push({ path, content: applyDraft(page.content, prose) });
|
|
331
|
+
}
|
|
332
|
+
return { edits };
|
|
333
|
+
};
|
|
334
|
+
// The one place the draft branch and its collision message live. The shell asks this before it
|
|
335
|
+
// spends on the model, so a repeat run stops for free instead of drafting into a branch it can
|
|
336
|
+
// never open; openDraftPr asks it again at the last moment as the real safety.
|
|
337
|
+
export const draftBranchConflict = async (git) => (await git.branchExists(DRAFT_BRANCH))
|
|
338
|
+
? `branch ${DRAFT_BRANCH} already exists from an earlier draft`
|
|
339
|
+
: undefined;
|
|
340
|
+
// Opens the PR for drafts the shell has already written to disk: branch, commit, push, open —
|
|
341
|
+
// the same moves as cut, but it stops at the open. Where release cut pauses for a human to
|
|
342
|
+
// merge, this never merges: machine-drafted prose is a suggestion, and the PR body says so, so
|
|
343
|
+
// the worst a bad draft can do is wait in review. `git switch -c` keeps the working-tree edits,
|
|
344
|
+
// so the branch is created after the write. Like cut, it refuses a branch an earlier draft left
|
|
345
|
+
// behind rather than letting git throw on the collision.
|
|
346
|
+
export const openDraftPr = async ({ specifiers, git, gh, }) => {
|
|
347
|
+
const conflict = await draftBranchConflict(git);
|
|
348
|
+
if (conflict !== undefined) {
|
|
349
|
+
return { stopped: conflict };
|
|
350
|
+
}
|
|
351
|
+
await git.createBranch(DRAFT_BRANCH);
|
|
352
|
+
await git.commitAll('docs: draft the scaffolded entry point pages');
|
|
353
|
+
await git.push();
|
|
354
|
+
const body = [
|
|
355
|
+
'Machine-drafted documentation for entry points that carried only a scaffold. The prose is',
|
|
356
|
+
'unreviewed — read every line before merging, and treat it as a starting point, not an answer.',
|
|
357
|
+
'',
|
|
358
|
+
'Drafted:',
|
|
359
|
+
...specifiers.map((specifier) => `- \`${specifier}\``),
|
|
360
|
+
].join('\n');
|
|
361
|
+
return gh.openPr({ title: 'docs: draft the scaffolded entry point pages', body });
|
|
362
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ekohacks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "The EkoHacks CLI: our everyday operations, automated one small command at a time.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"test:package": "scripts/test-package.sh"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
+
"@anthropic-ai/sdk": "0.115.0",
|
|
33
34
|
"@commitlint/cli": "^21.2.1",
|
|
34
35
|
"@commitlint/config-conventional": "^21.2.0",
|
|
35
36
|
"@types/node": "^26.1.1",
|
|
@@ -40,5 +41,13 @@
|
|
|
40
41
|
},
|
|
41
42
|
"bin": {
|
|
42
43
|
"ekohacks": "dist/cli.js"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@anthropic-ai/sdk": "^0.115.0"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@anthropic-ai/sdk": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
43
52
|
}
|
|
44
53
|
}
|