nearly-cli 0.1.0 → 0.1.3
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 +7 -13
- package/package.json +2 -2
- package/scripts/attach.mjs +51 -12
- package/scripts/build-recap.mjs +4 -3
- package/scripts/post-recap.mjs +2 -1
- package/scripts/publish-pages.mjs +4 -3
- package/scripts/push-record.mjs +2 -1
- package/server/index.mjs +2 -1
- package/server/paths.mjs +38 -0
package/README.md
CHANGED
|
@@ -31,31 +31,25 @@ Gating, recording and the record itself work on macOS, Linux and Windows. **Spok
|
|
|
31
31
|
One command, in the repo you want recorded.
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
|
|
35
|
-
nearly
|
|
34
|
+
npx nearly-cli
|
|
36
35
|
```
|
|
37
36
|
|
|
38
37
|
```
|
|
39
38
|
✓ Nearly is on for my-app
|
|
40
39
|
|
|
41
40
|
· every Claude Code session here is gated and recorded
|
|
41
|
+
· upgrades reach this repo automatically
|
|
42
42
|
· the record is offered when you push
|
|
43
|
-
· records publish to https://you.github.io/nearly/records
|
|
44
43
|
|
|
45
44
|
Now just work. Requests that need you appear at http://127.0.0.1:47653
|
|
46
45
|
Nothing to leave running. Turn it off again with --off.
|
|
47
46
|
```
|
|
48
47
|
|
|
49
|
-
|
|
48
|
+
That installs Nearly, turns it on for this repo, and works out where records go.
|
|
49
|
+
Nothing to configure, no server to start, and `nearly off` removes all of it.
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
```bash
|
|
54
|
-
git clone https://github.com/anujpatel06/nearly ~/nearly
|
|
55
|
-
npm link --prefix ~/nearly
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
No dependencies, so the link is instant. Once published it becomes `npx nearly-cli` with nothing to clone at all.
|
|
51
|
+
Run it again in any other repo you want recorded. After the first time the
|
|
52
|
+
command is just `nearly`.
|
|
59
53
|
|
|
60
54
|
### Upgrading
|
|
61
55
|
|
|
@@ -95,7 +89,7 @@ Nothing about how you work changes. Open the repo in VS Code or a terminal, star
|
|
|
95
89
|
|
|
96
90
|
### For a team
|
|
97
91
|
|
|
98
|
-
`.claude/settings.local.json` is per-person and stays out of git, which is right while you are trying it. To turn it on for everyone, move the same hooks into `.claude/settings.json` and commit that file.
|
|
92
|
+
`.claude/settings.local.json` is per-person and stays out of git, which is right while you are trying it. To turn it on for everyone, move the same hooks into `.claude/settings.json` and commit that file. The hooks invoke `nearly` by name, so a teammate who clones the repo runs `npx nearly-cli` once and is set up.
|
|
99
93
|
|
|
100
94
|
## Why the gate is not the point
|
|
101
95
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nearly-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "A pull request tells you what changed. Nearly tells you what nearly happened: the commands a human refused, the pushes policy blocked, the turns rolled back.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"license": "MIT",
|
|
31
31
|
"repository": {
|
|
32
32
|
"type": "git",
|
|
33
|
-
"url": "https://github.com/anujpatel06/nearly.git"
|
|
33
|
+
"url": "git+https://github.com/anujpatel06/nearly.git"
|
|
34
34
|
},
|
|
35
35
|
"homepage": "https://anujpatel06.github.io/nearly/",
|
|
36
36
|
"scripts": {
|
package/scripts/attach.mjs
CHANGED
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
// it, and if it cannot start, Claude Code falls back to its own prompts and
|
|
17
17
|
// nothing breaks.
|
|
18
18
|
|
|
19
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
|
|
20
20
|
import { join, resolve, dirname, basename } from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
23
|
+
import { dataRoot } from '../server/paths.mjs';
|
|
23
24
|
|
|
24
25
|
const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
25
26
|
const HOOK = join(root, 'scripts', 'hook.mjs');
|
|
@@ -39,21 +40,55 @@ function pkgVersion() {
|
|
|
39
40
|
catch { return 'latest'; }
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
// Only trust a `nearly` on PATH if it really is this tool
|
|
43
|
+
// Only trust a `nearly` on PATH if it really is this tool and it will still be
|
|
44
|
+
// there tomorrow.
|
|
45
|
+
//
|
|
46
|
+
// npx puts its own temporary bin first on PATH for the life of the process, so
|
|
47
|
+
// during `npx nearly-cli` a naive lookup finds a `nearly` that ceases to exist
|
|
48
|
+
// the moment npx exits. Believing it meant writing hooks that call a command
|
|
49
|
+
// nobody has, which fail on every tool call, which means no gate at all.
|
|
43
50
|
function onPath() {
|
|
51
|
+
const args = process.platform === 'win32' ? ['nearly'] : ['-a', 'nearly'];
|
|
52
|
+
let found = [];
|
|
44
53
|
try {
|
|
45
|
-
|
|
46
|
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split('\n')
|
|
47
|
-
if (!p) return null;
|
|
48
|
-
const out = execFileSync(p, ['--which'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
49
|
-
return out ? p : null;
|
|
54
|
+
found = execFileSync(process.platform === 'win32' ? 'where' : 'which', args,
|
|
55
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split('\n').filter(Boolean);
|
|
50
56
|
} catch { return null; }
|
|
57
|
+
|
|
58
|
+
for (const p of found) {
|
|
59
|
+
let resolved = p;
|
|
60
|
+
try { resolved = realpathSync(p); } catch { /* keep the literal path */ }
|
|
61
|
+
if (/[\\/]_npx[\\/]/.test(p) || /[\\/]_npx[\\/]/.test(resolved)) continue; // vanishes when npx exits
|
|
62
|
+
try {
|
|
63
|
+
const out = execFileSync(p, ['--which'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
64
|
+
if (out) return p;
|
|
65
|
+
} catch { /* not our command; try the next one */ }
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
51
68
|
}
|
|
52
69
|
|
|
53
70
|
const fromPackage = /[\\/]node_modules[\\/]/.test(root) || /[\\/]_npx[\\/]/.test(root);
|
|
54
|
-
const
|
|
71
|
+
const viaNpx = /[\\/]_npx[\\/]/.test(root);
|
|
72
|
+
|
|
73
|
+
// `npx nearly-cli` should be the whole of it. An npx run is a temporary
|
|
74
|
+
// download, so hooks pointing at it would pin a version in a directory npm
|
|
75
|
+
// clears, and no fix would ever reach this repo. Install it properly instead,
|
|
76
|
+
// once, out loud, so the one command someone types actually leaves them with a
|
|
77
|
+
// working tool. --no-install skips it.
|
|
78
|
+
function installGlobally() {
|
|
79
|
+
if (!viaNpx || onPath() || argv.includes('--no-install') || process.env.NEARLY_NO_INSTALL === '1') return false;
|
|
80
|
+
process.stdout.write(dim(` Installing nearly so upgrades reach you… `));
|
|
81
|
+
const r = spawnSync('npm', ['install', '-g', `nearly-cli@${pkgVersion()}`, '--silent', '--no-fund', '--no-audit'],
|
|
82
|
+
{ encoding: 'utf8', timeout: 180_000 });
|
|
83
|
+
if (r.status === 0 && onPath()) { console.log('done'); return true; }
|
|
84
|
+
console.log(dim('skipped'));
|
|
85
|
+
console.log(dim(' Running from npx instead. Upgrades will not reach this repo automatically;'));
|
|
86
|
+
console.log(dim(' npm install -g nearly-cli when you want that.'));
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
let installed = onPath();
|
|
55
90
|
const hookCmd = (ev) => installed
|
|
56
|
-
? `nearly hook ${ev}`
|
|
91
|
+
? `nearly hook ${ev}` // verified above to survive this process
|
|
57
92
|
: fromPackage
|
|
58
93
|
? `npx -y nearly-cli@${pkgVersion()} hook ${ev}`
|
|
59
94
|
: `node ${JSON.stringify(HOOK)} ${ev}`;
|
|
@@ -80,6 +115,10 @@ if (!existsSync(join(repo, '.git'))) {
|
|
|
80
115
|
process.exit(1);
|
|
81
116
|
}
|
|
82
117
|
|
|
118
|
+
// Do this before the hooks are written, so they can point at the installed
|
|
119
|
+
// command rather than a temporary npx download.
|
|
120
|
+
if (!off && installGlobally()) installed = onPath();
|
|
121
|
+
|
|
83
122
|
// ---------------------------------------------------------------------------
|
|
84
123
|
// Claude Code hooks
|
|
85
124
|
// ---------------------------------------------------------------------------
|
|
@@ -185,9 +224,9 @@ if (base) {
|
|
|
185
224
|
} else {
|
|
186
225
|
// Only reachable from a clone of the upstream repo, where publishing needs a
|
|
187
226
|
// fork the person actually controls.
|
|
188
|
-
console.log(` ${ok('·')} ${dim(
|
|
189
|
-
console.log(` ${dim('to
|
|
190
|
-
console.log(` ${dim('NEARLY_URL_BASE=https
|
|
227
|
+
console.log(` ${ok('·')} ${dim(`records are kept in ${dataRoot.replace(process.env.HOME || '~', '~')}`)}`);
|
|
228
|
+
console.log(` ${dim('to link them from a pull request, host that folder anywhere and:')}`);
|
|
229
|
+
console.log(` ${dim('NEARLY_URL_BASE=https://your-host/records nearly')}`);
|
|
191
230
|
}
|
|
192
231
|
console.log('');
|
|
193
232
|
console.log(` Now just work. Requests that need you appear at ${bold(`http://127.0.0.1:${PORT}`)}`);
|
package/scripts/build-recap.mjs
CHANGED
|
@@ -16,11 +16,12 @@ import { join, dirname, basename, resolve } from 'node:path';
|
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
18
18
|
import { tmpdir } from 'node:os';
|
|
19
|
+
import { paths } from '../server/paths.mjs';
|
|
19
20
|
|
|
20
21
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
-
const recordingsDir =
|
|
22
|
+
const recordingsDir = paths.recordings();
|
|
22
23
|
const templatePath = join(root, 'ui', 'recap.template.html');
|
|
23
|
-
const outDir =
|
|
24
|
+
const outDir = paths.pages();
|
|
24
25
|
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
26
27
|
// args
|
|
@@ -635,7 +636,7 @@ if (!noAudio) {
|
|
|
635
636
|
sb.totalS = sb.scenes.reduce((n, s) => n + s.durS, 0);
|
|
636
637
|
|
|
637
638
|
mkdirSync(outDir, { recursive: true });
|
|
638
|
-
const storyDirOut =
|
|
639
|
+
const storyDirOut = paths.records();
|
|
639
640
|
mkdirSync(storyDirOut, { recursive: true });
|
|
640
641
|
const safe = (x) => String(x).replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
|
|
641
642
|
const slug = BRANCH ? `${safe(sb.name)}--${safe(BRANCH)}` : `${sb.name}-${sb.id.slice(0, 4)}`;
|
package/scripts/post-recap.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { join, dirname } from 'node:path';
|
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { spawnSync } from 'node:child_process';
|
|
16
16
|
import { tmpdir } from 'node:os';
|
|
17
|
+
import { paths } from '../server/paths.mjs';
|
|
17
18
|
|
|
18
19
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
20
|
const argv = process.argv.slice(2);
|
|
@@ -22,7 +23,7 @@ const ubIdx = argv.indexOf('--url-base');
|
|
|
22
23
|
const urlBase = (ubIdx !== -1 ? argv[ubIdx + 1] : process.env.NEARLY_URL_BASE || '').replace(/\/$/, '');
|
|
23
24
|
const target = argv.find((a, i) => !a.startsWith('--') && argv[i - 1] !== '--url-base') || 'latest';
|
|
24
25
|
|
|
25
|
-
const dir =
|
|
26
|
+
const dir = paths.records();
|
|
26
27
|
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
27
28
|
if (!files.length) { console.error('no storyboards in records/. Run build-recap first.'); process.exit(1); }
|
|
28
29
|
let file;
|
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
import { readFileSync, writeFileSync, readdirSync, mkdirSync, copyFileSync, existsSync, statSync } from 'node:fs';
|
|
17
17
|
import { join, dirname } from 'node:path';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { paths } from '../server/paths.mjs';
|
|
19
20
|
|
|
20
21
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
-
const storyDir =
|
|
22
|
-
const builtDir =
|
|
23
|
-
const docsDir =
|
|
22
|
+
const storyDir = paths.records();
|
|
23
|
+
const builtDir = paths.pages();
|
|
24
|
+
const docsDir = paths.docs();
|
|
24
25
|
const outRecaps = join(docsDir, 'records');
|
|
25
26
|
|
|
26
27
|
const argv = process.argv.slice(2);
|
package/scripts/push-record.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { join, dirname, resolve, basename } from 'node:path';
|
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
12
12
|
import { createInterface } from 'node:readline';
|
|
13
|
+
import { paths } from '../server/paths.mjs';
|
|
13
14
|
|
|
14
15
|
const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
15
16
|
const repo = resolve(process.argv[2] || '.');
|
|
@@ -55,7 +56,7 @@ if (build.status !== 0) {
|
|
|
55
56
|
|
|
56
57
|
const safe = (x) => String(x).replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
|
|
57
58
|
const slug = `${safe(basename(repo))}--${safe(branch)}`;
|
|
58
|
-
const storyPath = join(
|
|
59
|
+
const storyPath = join(paths.records(), `${slug}.json`);
|
|
59
60
|
if (!existsSync(storyPath)) bail('record built but not found on disk');
|
|
60
61
|
|
|
61
62
|
const sb = JSON.parse(readFileSync(storyPath, 'utf8'));
|
package/server/index.mjs
CHANGED
|
@@ -11,13 +11,14 @@ import path from 'node:path';
|
|
|
11
11
|
import { randomUUID } from 'node:crypto';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { DEFAULT_TIER, ruleKey, classify as classifyWith } from './policy.mjs';
|
|
14
|
+
import { paths } from './paths.mjs';
|
|
14
15
|
|
|
15
16
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
17
|
const PORT = Number(process.env.NEARLY_PORT || 47653);
|
|
17
18
|
const HOST = '127.0.0.1';
|
|
18
19
|
const WORKSPACE = path.join(ROOT, 'workspace');
|
|
19
20
|
const WORKTREES = path.join(WORKSPACE, '.worktrees');
|
|
20
|
-
const RECORDINGS =
|
|
21
|
+
const RECORDINGS = paths.recordings();
|
|
21
22
|
const UI = path.join(ROOT, 'ui', 'index.html');
|
|
22
23
|
const MAX_SESSIONS = 3; // 8 GB machine
|
|
23
24
|
const ASK_TIMEOUT_MS = Number(process.env.NEARLY_ASK_TIMEOUT_MS || 120_000); // UI must answer before this; then we fail CLOSED (deny)
|
package/server/paths.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Where Nearly keeps what it records.
|
|
2
|
+
//
|
|
3
|
+
// Installed from npm, the package directory is replaced wholesale on every
|
|
4
|
+
// upgrade, so anything written there is destroyed by the next `npm install -g`.
|
|
5
|
+
// Recordings are the one thing in this project that cannot be regenerated:
|
|
6
|
+
// every record, every count, every refusal is derived from them. They belong in
|
|
7
|
+
// the user's own space.
|
|
8
|
+
//
|
|
9
|
+
// Run from a checkout, the checkout is the workspace, which is what a
|
|
10
|
+
// contributor expects and what the tests rely on.
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
13
|
+
import { join, dirname } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const pkgRoot = join(here, '..');
|
|
19
|
+
|
|
20
|
+
// A checkout has a .git directory; an installed package does not.
|
|
21
|
+
export const fromCheckout = existsSync(join(pkgRoot, '.git'));
|
|
22
|
+
|
|
23
|
+
export const dataRoot = fromCheckout
|
|
24
|
+
? pkgRoot
|
|
25
|
+
: join(process.env.NEARLY_HOME || join(homedir(), '.nearly'));
|
|
26
|
+
|
|
27
|
+
export function dataDir(...parts) {
|
|
28
|
+
const p = join(dataRoot, ...parts);
|
|
29
|
+
try { mkdirSync(p, { recursive: true }); } catch { /* caller will report */ }
|
|
30
|
+
return p;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const paths = {
|
|
34
|
+
recordings: () => process.env.NEARLY_RECORDINGS || dataDir('recordings'),
|
|
35
|
+
records: () => process.env.NEARLY_STORY || dataDir('records'),
|
|
36
|
+
pages: () => process.env.NEARLY_OUT || (fromCheckout ? join(pkgRoot, 'ui', 'records') : dataDir('pages', 'records')),
|
|
37
|
+
docs: () => (fromCheckout ? join(pkgRoot, 'docs') : dataDir('pages')),
|
|
38
|
+
};
|