copperhead 0.7.0 → 0.8.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 +36 -4
- package/dist/agent/animate.js +76 -0
- package/dist/agent/animate.js.map +1 -0
- package/dist/agent/box.js +89 -0
- package/dist/agent/box.js.map +1 -0
- package/dist/agent/dock-renderer.js +173 -0
- package/dist/agent/dock-renderer.js.map +1 -0
- package/dist/agent/logo.js +21 -0
- package/dist/agent/logo.js.map +1 -0
- package/dist/agent/loop.js +15 -5
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/providers/claude-code.js +1 -212
- package/dist/agent/providers/claude-code.js.map +1 -1
- package/dist/agent/providers/cursor.js +317 -0
- package/dist/agent/providers/cursor.js.map +1 -0
- package/dist/agent/providers/tool-protocol.js +205 -0
- package/dist/agent/providers/tool-protocol.js.map +1 -0
- package/dist/agent/render.js +32 -15
- package/dist/agent/render.js.map +1 -1
- package/dist/agent/runmeta.js +4 -5
- package/dist/agent/runmeta.js.map +1 -1
- package/dist/agent/theme.js +84 -0
- package/dist/agent/theme.js.map +1 -0
- package/dist/cli.js +134 -13
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.js +41 -32
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/demo.js +146 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/doctor.js +240 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/repl-inspect.js +342 -0
- package/dist/commands/repl-inspect.js.map +1 -0
- package/dist/commands/repl.js +618 -0
- package/dist/commands/repl.js.map +1 -0
- package/dist/config.js +5 -2
- package/dist/config.js.map +1 -1
- package/dist/kicad/cli.js +126 -6
- package/dist/kicad/cli.js.map +1 -1
- package/dist/util/cli-args.js +35 -0
- package/dist/util/cli-args.js.map +1 -0
- package/dist/util/dock.js +155 -0
- package/dist/util/dock.js.map +1 -0
- package/dist/util/git.js +129 -4
- package/dist/util/git.js.map +1 -1
- package/dist/util/live-prompt.js +542 -0
- package/dist/util/live-prompt.js.map +1 -0
- package/dist/util/paths.js +9 -0
- package/dist/util/paths.js.map +1 -1
- package/dist/util/select.js +172 -0
- package/dist/util/select.js.map +1 -0
- package/package.json +3 -2
- package/src/agent/animate.ts +90 -0
- package/src/agent/box.ts +99 -0
- package/src/agent/dock-renderer.ts +181 -0
- package/src/agent/logo.ts +23 -0
- package/src/agent/loop.ts +15 -5
- package/src/agent/providers/claude-code.ts +2 -216
- package/src/agent/providers/cursor.ts +364 -0
- package/src/agent/providers/tool-protocol.ts +212 -0
- package/src/agent/render.ts +33 -16
- package/src/agent/runmeta.ts +6 -7
- package/src/agent/theme.ts +91 -0
- package/src/cli.ts +139 -15
- package/src/commands/create.ts +81 -30
- package/src/commands/demo.ts +184 -0
- package/src/commands/doctor.ts +289 -0
- package/src/commands/repl-inspect.ts +353 -0
- package/src/commands/repl.ts +685 -0
- package/src/config.ts +6 -3
- package/src/kicad/cli.ts +132 -7
- package/src/layout/claude-ui-layout.md +72 -0
- package/src/layout/repl-ui-layout.md +139 -0
- package/src/util/cli-args.ts +42 -0
- package/src/util/dock.ts +161 -0
- package/src/util/git.ts +140 -4
- package/src/util/live-prompt.ts +595 -0
- package/src/util/paths.ts +10 -0
- package/src/util/select.ts +192 -0
package/src/util/git.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execa } from 'execa';
|
|
2
|
-
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { access, constants, cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
@@ -42,6 +42,12 @@ export async function ensureIgnored(repo: string, entries: string[]): Promise<vo
|
|
|
42
42
|
export interface GitSnapshot {
|
|
43
43
|
head: string;
|
|
44
44
|
stash: string | null;
|
|
45
|
+
/**
|
|
46
|
+
* Tree object holding the untracked-but-not-ignored files that `git stash
|
|
47
|
+
* create` cannot capture. Without it a rollback's `git clean -fd` deletes
|
|
48
|
+
* them for good; see snapshotUntracked().
|
|
49
|
+
*/
|
|
50
|
+
untracked: string | null;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
async function git(repo: string, args: string[]): Promise<string> {
|
|
@@ -49,6 +55,121 @@ async function git(repo: string, args: string[]): Promise<string> {
|
|
|
49
55
|
return stdout.trim();
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
/** Same as git(), with a scratch index so the repo's real index is untouched. */
|
|
59
|
+
async function gitWithIndex(repo: string, indexFile: string, args: string[]): Promise<string> {
|
|
60
|
+
const { stdout } = await execa('git', args, {
|
|
61
|
+
cwd: repo,
|
|
62
|
+
env: { GIT_INDEX_FILE: indexFile },
|
|
63
|
+
});
|
|
64
|
+
return stdout.trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Monotonic suffix so two scratch indexes in one process never collide. */
|
|
68
|
+
let scratchIndexSeq = 0;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Path for a throwaway index, inside the repo's own git dir rather than
|
|
72
|
+
* TMPDIR: git guarantees that directory exists and is writable, so a hostile
|
|
73
|
+
* or missing temp dir cannot make a snapshot fail and block a run from
|
|
74
|
+
* starting. Absolute, because git resolves GIT_INDEX_FILE against cwd.
|
|
75
|
+
*/
|
|
76
|
+
async function scratchIndexPath(repo: string): Promise<string> {
|
|
77
|
+
const gitDir = await git(repo, ['rev-parse', '--absolute-git-dir']);
|
|
78
|
+
return path.join(gitDir, `copperhead-index-${process.pid}-${scratchIndexSeq++}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Write the untracked-but-not-ignored files to a tree object and return its
|
|
83
|
+
* sha, or null when there are none.
|
|
84
|
+
*
|
|
85
|
+
* `git stash create` only ever captures tracked changes, so on its own it
|
|
86
|
+
* leaves every new file a run (or the user) has not added yet outside the
|
|
87
|
+
* snapshot — and restore()'s `git clean -fd` then deletes exactly those. The
|
|
88
|
+
* two sets line up: `--exclude-standard` skips ignored paths and plain
|
|
89
|
+
* `clean -fd` (no -x) leaves them alone, so what is captured here is precisely
|
|
90
|
+
* what the rollback would otherwise destroy.
|
|
91
|
+
*
|
|
92
|
+
* Built through a scratch GIT_INDEX_FILE rather than `git add -A`, because
|
|
93
|
+
* this runs at the *start* of a run: staging the user's files would outlive a
|
|
94
|
+
* successful run and silently rewrite their staged/unstaged split.
|
|
95
|
+
*/
|
|
96
|
+
async function snapshotUntracked(repo: string): Promise<string | null> {
|
|
97
|
+
const listed = await git(repo, ['ls-files', '--others', '--exclude-standard', '-z']);
|
|
98
|
+
const paths = listed.split('\0').filter(Boolean);
|
|
99
|
+
if (!paths.length) return null;
|
|
100
|
+
const usable = await readableOnly(repo, paths);
|
|
101
|
+
if (!usable.length) return null;
|
|
102
|
+
const indexFile = await scratchIndexPath(repo);
|
|
103
|
+
try {
|
|
104
|
+
await execa('git', ['update-index', '-z', '--add', '--stdin'], {
|
|
105
|
+
cwd: repo,
|
|
106
|
+
env: { GIT_INDEX_FILE: indexFile },
|
|
107
|
+
input: usable.join('\0') + '\0',
|
|
108
|
+
});
|
|
109
|
+
return (await gitWithIndex(repo, indexFile, ['write-tree'])) || null;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// A path that passed the readability check above and still failed here
|
|
112
|
+
// lost the race (permissions or existence changed in between). Same
|
|
113
|
+
// refusal, since the same file would be destroyed by the rollback.
|
|
114
|
+
throw unsnapshottable(String((err as Error).message).split('\n')[0] ?? 'unknown path');
|
|
115
|
+
} finally {
|
|
116
|
+
await rm(indexFile, { force: true }).catch(() => {});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Drop untracked paths that no longer exist, and refuse on any that exist but
|
|
122
|
+
* cannot be read.
|
|
123
|
+
*
|
|
124
|
+
* `git update-index` aborts the whole batch with exit 128 on the first path it
|
|
125
|
+
* cannot open, and this runs before the first turn, so one stray root-owned or
|
|
126
|
+
* mode-000 file would otherwise refuse every `--allow-dirty` run with a bare
|
|
127
|
+
* `fatal: Unable to process path …`. A vanished path is dropped rather than
|
|
128
|
+
* refused: a file that no longer exists cannot be lost. One that exists but is
|
|
129
|
+
* unreadable is refused deliberately rather than skipped, because `restore()`'s
|
|
130
|
+
* `git clean -fd` deletes it either way, and skipping would quietly reinstate
|
|
131
|
+
* exactly the data loss the untracked snapshot exists to prevent.
|
|
132
|
+
*/
|
|
133
|
+
async function readableOnly(repo: string, paths: string[]): Promise<string[]> {
|
|
134
|
+
const usable: string[] = [];
|
|
135
|
+
for (const p of paths) {
|
|
136
|
+
try {
|
|
137
|
+
await access(path.join(repo, p), constants.R_OK);
|
|
138
|
+
usable.push(p);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
141
|
+
throw unsnapshottable(p);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return usable;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function unsnapshottable(what: string): PreflightError {
|
|
148
|
+
return new PreflightError(
|
|
149
|
+
`cannot read untracked file: ${what}`,
|
|
150
|
+
'a run started with --allow-dirty promises your uncommitted work survives a failed run, but a file copperhead cannot read cannot be snapshotted, and the rollback would delete it with nothing to restore it from',
|
|
151
|
+
[
|
|
152
|
+
`make it readable: chmod +r "${what}"`,
|
|
153
|
+
'or delete it, or add it to .gitignore so the rollback leaves it alone',
|
|
154
|
+
'or commit your work and rerun without --allow-dirty',
|
|
155
|
+
],
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Re-materialize the untracked files captured by snapshotUntracked(). Runs
|
|
161
|
+
* after `stash apply` so tracked restores win any path collision.
|
|
162
|
+
*/
|
|
163
|
+
async function restoreUntracked(repo: string, tree: string): Promise<void> {
|
|
164
|
+
const indexFile = await scratchIndexPath(repo);
|
|
165
|
+
try {
|
|
166
|
+
await gitWithIndex(repo, indexFile, ['read-tree', tree]);
|
|
167
|
+
await gitWithIndex(repo, indexFile, ['checkout-index', '-a', '-f']);
|
|
168
|
+
} finally {
|
|
169
|
+
await rm(indexFile, { force: true }).catch(() => {});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
52
173
|
export async function isGitRepo(repo: string): Promise<boolean> {
|
|
53
174
|
try {
|
|
54
175
|
await git(repo, ['rev-parse', '--git-dir']);
|
|
@@ -108,16 +229,19 @@ export async function gitPreflight(repo: string, opts: { allowDirty?: boolean }
|
|
|
108
229
|
|
|
109
230
|
/**
|
|
110
231
|
* Snapshot the working tree before a run. On a clean tree HEAD is enough;
|
|
111
|
-
* with --allow-dirty we keep a `git stash create` object
|
|
112
|
-
*
|
|
232
|
+
* with --allow-dirty we keep a `git stash create` object for tracked changes
|
|
233
|
+
* plus a tree of the untracked files it cannot see, so uncommitted work
|
|
234
|
+
* survives a rollback intact (SPEC §7).
|
|
113
235
|
*/
|
|
114
236
|
export async function snapshot(repo: string): Promise<GitSnapshot> {
|
|
115
237
|
const head = await git(repo, ['rev-parse', 'HEAD']);
|
|
116
238
|
let stash: string | null = null;
|
|
239
|
+
let untracked: string | null = null;
|
|
117
240
|
if (await isDirty(repo)) {
|
|
118
241
|
stash = (await git(repo, ['stash', 'create'])) || null;
|
|
242
|
+
untracked = await snapshotUntracked(repo);
|
|
119
243
|
}
|
|
120
|
-
return { head, stash };
|
|
244
|
+
return { head, stash, untracked };
|
|
121
245
|
}
|
|
122
246
|
|
|
123
247
|
/**
|
|
@@ -148,6 +272,18 @@ export async function restore(repo: string, snap: GitSnapshot): Promise<void> {
|
|
|
148
272
|
if (snap.stash) {
|
|
149
273
|
await git(repo, ['stash', 'apply', snap.stash]);
|
|
150
274
|
}
|
|
275
|
+
// The clean above deleted every untracked file; put back the ones that
|
|
276
|
+
// were there before the run. Never fatal: a rollback that restored the
|
|
277
|
+
// tracked state is still better than one that threw halfway.
|
|
278
|
+
if (snap.untracked) {
|
|
279
|
+
try {
|
|
280
|
+
await restoreUntracked(repo, snap.untracked);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.warn(
|
|
283
|
+
`warning: could not restore untracked files after rollback: ${(err as Error).message}`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
151
287
|
} finally {
|
|
152
288
|
if (backup && existsSync(backup)) {
|
|
153
289
|
try {
|