@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
- for (const line of markdown.split('\n')) {
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];
@@ -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, 'utf8');
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, 'utf8')),
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
- const global = readStore(globalStorePath());
122
- const project = readStore(projectStorePath(process.cwd()));
123
- const combined = {
124
- ...emptyStore(),
125
- rules: [...global.rules, ...project.rules].filter((rule) => options.scope === undefined || rule.scope === options.scope),
126
- };
127
- const json = serializeRuleStore(combined);
128
- if (options.out === undefined) {
129
- out(json.trimEnd());
130
- return;
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, 'utf8'));
154
+ incoming = parseRuleStore(decodeTextFile(await readFile(file)));
144
155
  }
145
156
  catch (error) {
146
- out(error instanceof Error ? error.message : String(error));
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
- for (const target of targets) {
155
- const rules = incoming.rules.filter((rule) => rule.scope === target.scope);
156
- if (rules.length === 0)
157
- continue;
158
- const result = mergeStores(readStore(target.path), { ...emptyStore(), rules });
159
- if (!options.dryRun)
160
- writeStore(target.path, result.store);
161
- out(`${target.scope}: ${result.added} added, ${result.merged} updated with new evidence, ${result.store.rules.length} total → ${target.path}`);
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, 'utf8');
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, 'utf8');
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);
Binary file
@@ -1,7 +1,8 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
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 = readFileSync(path, 'utf8');
17
+ contents = readTextFile(path);
16
18
  }
17
19
  catch {
18
20
  return emptyStore();
19
21
  }
20
- return parseRuleStore(contents);
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
- writeFileSync(path, serializeRuleStore(store), 'utf8');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lastboy/pai",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "PAI — Personal AI Supervisor: a local-first CLI that observes how you work with AI coding agents and turns it into knowledge you own",
5
5
  "keywords": [
6
6
  "cli",