ekohacks 0.3.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/README.md CHANGED
@@ -10,6 +10,8 @@ The release command is built: the four stories in [`stories/`](stories/) are imp
10
10
 
11
11
  The second command is `ekohacks docs check`, specified in [`stories/6-docs-check.md`](stories/6-docs-check.md): a gate that fails when a package's docs drift from its shipped exports map, born from the EkoLite 0.4.0 release shipping a wrong public surface. Its acceptance test is the same command run inside EkoLite.
12
12
 
13
+ `ekohacks docs sync` is the other half, specified in [`stories/8-docs-sync.md`](stories/8-docs-sync.md): it writes the edits the check can prove — the entry-point block, the prose counts — and scaffolds a stub page for an entry point the docs just gained, leaving the prose to a person. [`stories/9-docs-draft.md`](stories/9-docs-draft.md) specifies drafting that prose and opening a PR for a human to review; it is written down, not built.
14
+
13
15
  ## How this is built
14
16
 
15
17
  - A tested core policy behind nullable infrastructure, in the style of [James Shore's Testing Without Mocks](https://www.jamesshore.com/v2/projects/nullables): real wrappers around `git`, `gh` and `npm`, each with a `createNull()` that answers with configurable responses and records what was asked of it. No mocks, no spies.
package/dist/cli.js CHANGED
@@ -2,31 +2,38 @@
2
2
  // The thin shell: read the repo's files, wire the real wrappers, print one line per
3
3
  // check and per step, exit 0 only when the command ran to its end. Everything worth
4
4
  // testing lives below.
5
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
6
+ import { dirname } from 'node:path';
6
7
  import { createInterface } from 'node:readline/promises';
7
8
  import { GhWrapper } from './infrastructure/gh.js';
8
9
  import { GitWrapper } from './infrastructure/git.js';
9
10
  import { NpmWrapper } from './infrastructure/npm.js';
10
11
  import { ProcessRunner } from './infrastructure/process.js';
12
+ import { ClaudeWrapper } from './infrastructure/claude.js';
11
13
  import { cut } from './logic/cut.js';
12
- import { docsCheck } from './logic/docs.js';
14
+ import { docsCheck, docsDraft, docsSync, draftBranchConflict, draftPrompts, openDraftPr, } from './logic/docs.js';
13
15
  import { preflight } from './logic/preflight.js';
14
16
  import { release } from './logic/release.js';
15
17
  import { ship } from './logic/ship.js';
16
18
  const USAGE = [
17
19
  'usage: ekohacks release [preflight|cut|ship] <version> [--yes]',
18
20
  ' ekohacks docs check',
21
+ ' ekohacks docs sync [--dry-run]',
22
+ ' ekohacks docs draft [--yes]',
19
23
  ].join('\n');
24
+ const FLAGS = ['--yes', '--dry-run'];
20
25
  const argv = process.argv.slice(2);
21
26
  const yes = argv.includes('--yes');
22
- const [command, ...rest] = argv.filter((arg) => arg !== '--yes');
27
+ const dryRun = argv.includes('--dry-run');
28
+ const [command, ...rest] = argv.filter((arg) => !FLAGS.includes(arg));
23
29
  const printChecks = (checks) => {
24
30
  for (const check of checks) {
25
31
  console.log(check.passed ? ` ok ${check.name}` : ` FAIL ${check.name}: ${check.reason}`);
26
32
  }
27
33
  };
28
34
  if (command === 'docs') {
29
- if (rest.length !== 1 || rest[0] !== 'check') {
35
+ const subject = rest[0];
36
+ if (rest.length !== 1 || (subject !== 'check' && subject !== 'sync' && subject !== 'draft')) {
30
37
  console.error(USAGE);
31
38
  process.exit(2);
32
39
  }
@@ -46,6 +53,84 @@ if (command === 'docs') {
46
53
  }
47
54
  }
48
55
  }
56
+ if (subject === 'sync') {
57
+ const { edits } = docsSync({ pkg: manifest.name, exports: manifest.exports, files });
58
+ if (edits.length === 0) {
59
+ console.log(' the docs are already in step');
60
+ process.exit(0);
61
+ }
62
+ for (const edit of edits) {
63
+ const verb = existsSync(edit.path) ? 'updated' : 'created';
64
+ if (!dryRun) {
65
+ mkdirSync(dirname(edit.path), { recursive: true });
66
+ writeFileSync(edit.path, edit.content);
67
+ }
68
+ console.log(` ${dryRun ? `would have ${verb}` : verb} ${edit.path}`);
69
+ }
70
+ process.exit(0);
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
+ }
49
134
  const report = await docsCheck({
50
135
  pkg: manifest.name,
51
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
+ }
@@ -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.
@@ -50,9 +56,12 @@ const COUNT_WORDS = {
50
56
  nine: 9,
51
57
  ten: 10,
52
58
  };
53
- const countClaimsIn = (content) => [
54
- ...content.matchAll(/\b(\d+|zero|one|two|three|four|five|six|seven|eight|nine|ten)[\s-]+entry[\s-]+points?\b/gi),
55
- ].map(([, claim = '']) => COUNT_WORDS[claim.toLowerCase()] ?? Number(claim));
59
+ // One definition of what a claim looks like, read by the check and rewritten by the sync — two
60
+ // copies of it would be their own drift. Built fresh at each use: a shared global regex carries
61
+ // its lastIndex from call to call.
62
+ const COUNT_CLAIM_SOURCE = String.raw `\b(\d+|zero|one|two|three|four|five|six|seven|eight|nine|ten)[\s-]+entry[\s-]+points?\b`;
63
+ const countClaims = () => new RegExp(COUNT_CLAIM_SOURCE, 'gi');
64
+ const countClaimsIn = (content) => [...content.matchAll(countClaims())].map(([, claim = '']) => COUNT_WORDS[claim.toLowerCase()] ?? Number(claim));
56
65
  // The drift detector as a policy: the exports map is the truth, the docs carry their
57
66
  // claims in a block the tool owns, and every mismatch is a named check with the reason
58
67
  // a human needs to fix it. The caller supplies file contents and the runner; the policy
@@ -108,3 +117,246 @@ export const docsCheck = async ({ pkg, exports, files, runner, }) => {
108
117
  : { name: 'docs build', passed: false, reason: `${DOCS_BUILD_COMMAND} failed` });
109
118
  return { checks };
110
119
  };
120
+ // Walks the same markers blockRegions does, but rebuilds the document around each region so a
121
+ // rewrite can be handed back in place. An unclosed block ends the walk with the rest of the
122
+ // document untouched: the check already fails it by name, and a sync that guessed where the
123
+ // block ended would be guessing with someone's prose.
124
+ const mapBlocks = (content, rewrite) => {
125
+ let rest = content;
126
+ let out = '';
127
+ for (;;) {
128
+ const open = rest.indexOf(OPEN_MARKER);
129
+ if (open === -1) {
130
+ return out + rest;
131
+ }
132
+ const afterOpen = rest.slice(open + OPEN_MARKER.length);
133
+ const close = afterOpen.indexOf(CLOSE_MARKER);
134
+ if (close === -1) {
135
+ return out + rest;
136
+ }
137
+ out += rest.slice(0, open) + OPEN_MARKER + rewrite(afterOpen.slice(0, close)) + CLOSE_MARKER;
138
+ rest = afterOpen.slice(close + CLOSE_MARKER.length);
139
+ }
140
+ };
141
+ // A namespace import is the one form the exports map alone can justify: it needs the specifier
142
+ // and nothing else. The local name is the last segment, camel-cased past any character an
143
+ // identifier cannot carry.
144
+ const importLineFor = (specifier) => {
145
+ const [first = specifier, ...rest] = (specifier.split('/').at(-1) ?? specifier).split(/[^a-zA-Z0-9]+/);
146
+ const local = [first, ...rest.map((part) => part.charAt(0).toUpperCase() + part.slice(1))].join('');
147
+ return `import * as ${local} from '${specifier}';`;
148
+ };
149
+ // Both edits to a block are line surgery on the specifiers it already lists: a line naming a
150
+ // specifier the exports no longer declare goes, and a specifier no line names arrives after the
151
+ // last one that survived. Lines naming another package are not the block's business — the check
152
+ // ignores them, and so does this.
153
+ const syncedBlock = (region, pkg, entries) => {
154
+ const kept = region
155
+ .split('\n')
156
+ .filter((line) => specifiersIn(line, pkg).every((specifier) => entries.includes(specifier)));
157
+ const documented = new Set(specifiersIn(kept.join('\n'), pkg));
158
+ const missing = entries.filter((entry) => !documented.has(entry));
159
+ const lastImport = kept.reduce((found, line, index) => (specifiersIn(line, pkg).length > 0 ? index : found), -1);
160
+ kept.splice(lastImport + 1, 0, ...missing.map(importLineFor));
161
+ return kept.join('\n');
162
+ };
163
+ // A page the tool can write without knowing anything it does not know: the import line it can
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;
171
+ const stubFor = (specifier) => {
172
+ const name = pageNameFor(specifier);
173
+ return {
174
+ path: `docs/${name}.md`,
175
+ content: [
176
+ `# ${specifier}`,
177
+ '',
178
+ DRAFT_OPEN,
179
+ '',
180
+ '```ts',
181
+ importLineFor(specifier),
182
+ '',
183
+ '// TODO: an example that runs.',
184
+ '```',
185
+ '',
186
+ '## What works today',
187
+ '',
188
+ '- TODO: what a reader can rely on today, not what is planned.',
189
+ '',
190
+ DRAFT_CLOSE,
191
+ '',
192
+ '<!-- TODO: add this page to the sidebar in docs/.vitepress/config.mts:',
193
+ ` { text: '${name}', link: '/${name}' } -->`,
194
+ '',
195
+ ].join('\n'),
196
+ };
197
+ };
198
+ const wordForCount = (count) => Object.entries(COUNT_WORDS).find(([, value]) => value === count)?.[0];
199
+ // A claim is rewritten in the form it was written: a digit stays a digit, a word stays a word
200
+ // carrying the case it had. Past ten the table has no word, so the claim becomes a digit — the
201
+ // same number the check reads either way.
202
+ const countAs = (written, count) => {
203
+ const word = wordForCount(count);
204
+ if (/^\d+$/.test(written) || word === undefined) {
205
+ return String(count);
206
+ }
207
+ return written.charAt(0) === written.charAt(0).toUpperCase()
208
+ ? word.charAt(0).toUpperCase() + word.slice(1)
209
+ : word;
210
+ };
211
+ const syncedCounts = (content, count) => content.replace(countClaims(), (claim) => {
212
+ const written = claim.split(/[\s-]/)[0] ?? '';
213
+ return countAs(written, count) + claim.slice(written.length);
214
+ });
215
+ // The mechanical half of the drift the check names: the same inputs, and instead of a report
216
+ // the files whose content should change, each as a whole new body. The shell does the writing,
217
+ // so the policy stays pure — and a file this would leave alone never reaches the caller, which
218
+ // is what makes a second run against a repo already in step write nothing.
219
+ export const docsSync = ({ pkg, exports, files, }) => {
220
+ const entries = entryPointsFrom(pkg, exports);
221
+ const scanned = files.filter((file) => !file.path.split('/').includes('.vitepress'));
222
+ const edits = [];
223
+ for (const file of scanned) {
224
+ const synced = mapBlocks(file.content, (region) => syncedBlock(region, pkg, entries));
225
+ const content = syncedCounts(synced, entries.length);
226
+ if (content !== file.content) {
227
+ edits.push({ path: file.path, content });
228
+ }
229
+ }
230
+ // What the docs declared before this run is the baseline a stub is new against. An unclosed
231
+ // block declares nothing readable and a repo with no block at all has no baseline, so neither
232
+ // gets scaffolded: both are failures the check already names, and guessing past them would
233
+ // stamp pages across a repo on the strength of a marker somebody forgot to close.
234
+ const readable = scanned
235
+ .map((file) => blockRegions(file.content))
236
+ .filter((block) => !block.unclosed && block.regions.length > 0);
237
+ const declared = new Set(readable.flatMap((block) => block.regions.flatMap((region) => specifiersIn(region, pkg))));
238
+ const existing = new Set(files.map((file) => file.path));
239
+ if (readable.length > 0) {
240
+ for (const entry of entries.filter((entry) => entry !== pkg && !declared.has(entry))) {
241
+ const stub = stubFor(entry);
242
+ if (!existing.has(stub.path)) {
243
+ edits.push(stub);
244
+ }
245
+ }
246
+ }
247
+ return { edits };
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.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
  }