@lastboy/pai 0.1.0 → 0.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/README.md
CHANGED
|
@@ -110,6 +110,13 @@ pai import mine.json
|
|
|
110
110
|
|
|
111
111
|
Invalid or unsupported files fail with a clear message and a non-zero exit code.
|
|
112
112
|
|
|
113
|
+
**Cross-platform.** A store exported on macOS or Linux imports on Windows and
|
|
114
|
+
back. Stores contain no filesystem paths, so nothing is machine-specific, and
|
|
115
|
+
import accepts the encodings Windows tooling produces: UTF-8, UTF-8 with a BOM
|
|
116
|
+
(Notepad, `Set-Content`) and UTF-16 with a BOM (PowerShell 5.1's `>` redirect).
|
|
117
|
+
Line endings are normalized, so the same rule written on Windows and on
|
|
118
|
+
macOS/Linux merges as one instead of duplicating.
|
|
119
|
+
|
|
113
120
|
### `pai experiment …`
|
|
114
121
|
|
|
115
122
|
Experimental commands. They may change or disappear, and they are the only
|
|
@@ -17,7 +17,8 @@ const BULLET = /^\s*[-*]\s+(.+?)\s*$/;
|
|
|
17
17
|
export function parseGuidelines(markdown) {
|
|
18
18
|
const guidelines = [];
|
|
19
19
|
let category = 'General';
|
|
20
|
-
|
|
20
|
+
// A BOM (Windows editors) would otherwise hide the first heading.
|
|
21
|
+
for (const line of markdown.replace(/^/, '').split('\n')) {
|
|
21
22
|
const heading = HEADING.exec(line);
|
|
22
23
|
if (heading?.[1]) {
|
|
23
24
|
category = heading[1];
|
package/dist/cli/program.js
CHANGED
|
@@ -16,6 +16,7 @@ import { findGuidelineFiles, parseGuidelines } from '../adapters/claude/guidelin
|
|
|
16
16
|
import { filterGuidelineGroups } from '../core/guidelines.js';
|
|
17
17
|
import { emptyStore, mergeStores, parseRuleStore, serializeRuleStore, } from '../core/rule-store.js';
|
|
18
18
|
import { globalStorePath, projectStorePath, readStore, writeStore, } from '../persistence/rule-store-files.js';
|
|
19
|
+
import { decodeTextFile } from '../persistence/text-file.js';
|
|
19
20
|
import { parseSelection, renderGuidelines, renderReview, renderSessionList } from './render.js';
|
|
20
21
|
async function ask(question) {
|
|
21
22
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -48,6 +49,10 @@ async function chooseSession(out) {
|
|
|
48
49
|
out('');
|
|
49
50
|
return sessions[index];
|
|
50
51
|
}
|
|
52
|
+
/** Node's fs errors are readable already; anything else gets stringified. */
|
|
53
|
+
function describeError(error) {
|
|
54
|
+
return error instanceof Error ? error.message : String(error);
|
|
55
|
+
}
|
|
51
56
|
// Same relative depth from src/cli and dist/cli.
|
|
52
57
|
function packageVersion() {
|
|
53
58
|
const pkg = JSON.parse(readFileSync(join(import.meta.dirname, '..', '..', 'package.json'), 'utf8'));
|
|
@@ -74,7 +79,7 @@ export function createProgram(out) {
|
|
|
74
79
|
process.exitCode = 1;
|
|
75
80
|
return;
|
|
76
81
|
}
|
|
77
|
-
const transcript = await readFile(chosen.path
|
|
82
|
+
const transcript = decodeTextFile(await readFile(chosen.path));
|
|
78
83
|
const session = parseTranscript(transcript, chosen.id);
|
|
79
84
|
for (const line of renderReview(buildReview(session))) {
|
|
80
85
|
out(line);
|
|
@@ -91,7 +96,7 @@ export function createProgram(out) {
|
|
|
91
96
|
const groups = await Promise.all(files.map(async (file) => ({
|
|
92
97
|
scope: file.scope,
|
|
93
98
|
path: file.path,
|
|
94
|
-
guidelines: parseGuidelines(await readFile(file.path
|
|
99
|
+
guidelines: parseGuidelines(decodeTextFile(await readFile(file.path))),
|
|
95
100
|
})));
|
|
96
101
|
// --global and --project together = no scope filter (same as neither).
|
|
97
102
|
const scope = options.global && !options.project
|
|
@@ -118,19 +123,25 @@ export function createProgram(out) {
|
|
|
118
123
|
process.exitCode = 1;
|
|
119
124
|
return;
|
|
120
125
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
126
|
+
try {
|
|
127
|
+
const global = readStore(globalStorePath());
|
|
128
|
+
const project = readStore(projectStorePath(process.cwd()));
|
|
129
|
+
const combined = {
|
|
130
|
+
...emptyStore(),
|
|
131
|
+
rules: [...global.rules, ...project.rules].filter((rule) => options.scope === undefined || rule.scope === options.scope),
|
|
132
|
+
};
|
|
133
|
+
const json = serializeRuleStore(combined);
|
|
134
|
+
if (options.out === undefined) {
|
|
135
|
+
out(json.trimEnd());
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
writeFileSync(options.out, json, 'utf8');
|
|
139
|
+
out(`Exported ${combined.rules.length} rule(s) to ${options.out}`);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
out(`Export failed: ${describeError(error)}`);
|
|
143
|
+
process.exitCode = 1;
|
|
131
144
|
}
|
|
132
|
-
writeFileSync(options.out, json, 'utf8');
|
|
133
|
-
out(`Exported ${combined.rules.length} rule(s) to ${options.out}`);
|
|
134
145
|
});
|
|
135
146
|
program
|
|
136
147
|
.command('import')
|
|
@@ -140,10 +151,10 @@ export function createProgram(out) {
|
|
|
140
151
|
.action(async (file, options) => {
|
|
141
152
|
let incoming;
|
|
142
153
|
try {
|
|
143
|
-
incoming = parseRuleStore(await readFile(file
|
|
154
|
+
incoming = parseRuleStore(decodeTextFile(await readFile(file)));
|
|
144
155
|
}
|
|
145
156
|
catch (error) {
|
|
146
|
-
out(
|
|
157
|
+
out(`Could not read ${file}: ${describeError(error)}`);
|
|
147
158
|
process.exitCode = 1;
|
|
148
159
|
return;
|
|
149
160
|
}
|
|
@@ -151,14 +162,22 @@ export function createProgram(out) {
|
|
|
151
162
|
{ scope: 'global', path: globalStorePath() },
|
|
152
163
|
{ scope: 'project', path: projectStorePath(process.cwd()) },
|
|
153
164
|
];
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
165
|
+
try {
|
|
166
|
+
for (const target of targets) {
|
|
167
|
+
const rules = incoming.rules.filter((rule) => rule.scope === target.scope);
|
|
168
|
+
if (rules.length === 0)
|
|
169
|
+
continue;
|
|
170
|
+
const result = mergeStores(readStore(target.path), { ...emptyStore(), rules });
|
|
171
|
+
if (!options.dryRun)
|
|
172
|
+
writeStore(target.path, result.store);
|
|
173
|
+
out(`${target.scope}: ${result.added} added, ${result.merged} updated with new evidence, ${result.store.rules.length} total → ${target.path}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
out(`Import failed: ${describeError(error)}`);
|
|
178
|
+
out('Nothing was changed for the scope that failed.');
|
|
179
|
+
process.exitCode = 1;
|
|
180
|
+
return;
|
|
162
181
|
}
|
|
163
182
|
if (incoming.rules.length === 0)
|
|
164
183
|
out('Nothing to import — the file contains no rules.');
|
|
@@ -179,7 +198,7 @@ export function createProgram(out) {
|
|
|
179
198
|
process.exitCode = 1;
|
|
180
199
|
return;
|
|
181
200
|
}
|
|
182
|
-
const transcript = await readFile(chosen.path
|
|
201
|
+
const transcript = decodeTextFile(await readFile(chosen.path));
|
|
183
202
|
const events = parseEvents(transcript);
|
|
184
203
|
const candidates = events
|
|
185
204
|
.map((event, index) => ({ event, index }))
|
|
@@ -265,7 +284,7 @@ export function createProgram(out) {
|
|
|
265
284
|
process.exitCode = 1;
|
|
266
285
|
return;
|
|
267
286
|
}
|
|
268
|
-
const transcript = await readFile(chosen.path
|
|
287
|
+
const transcript = decodeTextFile(await readFile(chosen.path));
|
|
269
288
|
const messages = parseEvents(transcript).filter((e) => e.kind === 'user');
|
|
270
289
|
const limit = Math.max(1, Number(options.limit) || 10);
|
|
271
290
|
const sample = messages.slice(-limit);
|
package/dist/core/rule-store.js
CHANGED
|
Binary file
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { mkdirSync,
|
|
1
|
+
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { emptyStore, parseRuleStore, serializeRuleStore } from '../core/rule-store.js';
|
|
5
|
+
import { readTextFile } from './text-file.js';
|
|
5
6
|
// PAI writes only to its own agent-neutral `.pai/` directories.
|
|
6
7
|
export function globalStorePath(home = homedir()) {
|
|
7
8
|
return join(home, '.pai', 'rules.json');
|
|
@@ -9,17 +10,40 @@ export function globalStorePath(home = homedir()) {
|
|
|
9
10
|
export function projectStorePath(cwd) {
|
|
10
11
|
return join(cwd, '.pai', 'rules.json');
|
|
11
12
|
}
|
|
13
|
+
/** A missing store is empty; a corrupt one is an error naming the file. */
|
|
12
14
|
export function readStore(path) {
|
|
13
15
|
let contents;
|
|
14
16
|
try {
|
|
15
|
-
contents =
|
|
17
|
+
contents = readTextFile(path);
|
|
16
18
|
}
|
|
17
19
|
catch {
|
|
18
20
|
return emptyStore();
|
|
19
21
|
}
|
|
20
|
-
|
|
22
|
+
try {
|
|
23
|
+
return parseRuleStore(contents);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)} — ${path}`);
|
|
27
|
+
}
|
|
21
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Written via a temporary file and renamed into place, so an interrupted
|
|
31
|
+
* write cannot leave a half-written store behind.
|
|
32
|
+
*/
|
|
22
33
|
export function writeStore(path, store) {
|
|
23
34
|
mkdirSync(dirname(path), { recursive: true });
|
|
24
|
-
|
|
35
|
+
const temporary = `${path}.tmp`;
|
|
36
|
+
try {
|
|
37
|
+
writeFileSync(temporary, serializeRuleStore(store), 'utf8');
|
|
38
|
+
renameSync(temporary, path);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
try {
|
|
42
|
+
unlinkSync(temporary);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Nothing to clean up.
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
25
49
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
// Files exported on one platform are routinely imported on another. Windows
|
|
3
|
+
// tooling adds byte-order marks and PowerShell 5.1's `>` redirect writes
|
|
4
|
+
// UTF-16LE, both of which break a plain UTF-8 read.
|
|
5
|
+
export function decodeTextFile(bytes) {
|
|
6
|
+
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
7
|
+
return bytes.subarray(3).toString('utf8');
|
|
8
|
+
}
|
|
9
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
|
|
10
|
+
return bytes.subarray(2).toString('utf16le');
|
|
11
|
+
}
|
|
12
|
+
if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
|
|
13
|
+
const body = bytes.subarray(2);
|
|
14
|
+
if (body.length % 2 !== 0)
|
|
15
|
+
return body.toString('utf8');
|
|
16
|
+
// swap16 mutates, so work on a copy of the caller's buffer.
|
|
17
|
+
return Buffer.from(body).swap16().toString('utf16le');
|
|
18
|
+
}
|
|
19
|
+
return bytes.toString('utf8');
|
|
20
|
+
}
|
|
21
|
+
export function readTextFile(path) {
|
|
22
|
+
return decodeTextFile(readFileSync(path));
|
|
23
|
+
}
|
package/package.json
CHANGED