petari 0.10.0 → 0.12.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
@@ -25,6 +25,12 @@ git diff # VS Code で差分レビュー・手修正
25
25
  petari undo # 直近の適用を巻き戻す
26
26
  ```
27
27
 
28
+ 往復が続くときは、別の端末で `petari watch` を起動しておくと、ダウンロードした
29
+ changes.md が届くたびに確認なしで適用されます (毎回 `petari` を打たなくてよくなります。
30
+ v0.12.0)。失敗したときはレポートがクリップボードに入るので、そのまま AI に貼り返します。
31
+ 止めるときは Ctrl+C です。ブラウザのダウンロード先とコードが同じ PC にあることが前提で、
32
+ RDP 越しに受け渡す運用では従来どおり `petari` / `petari --clip` を使います。
33
+
28
34
  ### slnmix 連携 (レガシー VB.NET プロジェクト)
29
35
 
30
36
  [slnmix](https://www.npmjs.com/package/slnmix) v0.7.0 以降は、入力 (.sln / .vbproj) と
@@ -32,12 +38,15 @@ petari undo # 直近の適用を巻き戻す
32
38
  `instructionFilePath` 相当。別の場所に置く場合は `--instruction-file <path>`)。
33
39
 
34
40
  ```sh
35
- npx petari init # protocol.md を生成 (.sln と同じプロジェクト直下に)
41
+ npx slnmix init # 初回だけ。protocol.md / .petari/ / docs/ / .gitignore をまとめて準備
42
+ # (slnmix v0.18.0 以降。内部で petari init --yes --no-repomix を実行)
36
43
  npx slnmix # .sln を解析。出力末尾に規約文が自動で付く
37
44
  # → 出力を M365 Copilot Chat に貼る
38
45
  petari # 返答 (changes.md) を適用
39
46
  ```
40
47
 
48
+ slnmix v0.17.0 以前では `npx petari init` で protocol.md を作ります (.sln と同じプロジェクト直下に)。
49
+
41
50
  **変更を依頼するメッセージには、必ず次の 1 行を添えてください。**
42
51
  貼り付け本文に埋め込まれた規約文は、チャットサービス側の要約・検索処理で
43
52
  モデルに届かなかったり、チャット本文の指示より弱く扱われたりすることがあります
@@ -86,11 +95,12 @@ slnmix の出力) が実ファイルとずれている**可能性が高いです
86
95
  | コマンド | 説明 |
87
96
  |---|---|
88
97
  | `petari [path]` | changes.md を適用。省略時は Downloads とプロジェクト直下から自動検出 (直近 30 分)。`--clip` でクリップボードから |
98
+ | `petari watch` | Downloads とプロジェクト直下を見張り、起動後に届いた changes*.md を確認なしで適用し続ける (Ctrl+C で終了)。`--dry-run` で検証とプレビューだけ |
89
99
  | `petari undo [ID]` | 履歴を巻き戻す (手修正がある場合は警告) |
90
100
  | `petari show [ID]` | 履歴の差分を表示 (VS Code、なければブラウザにフォールバック)。`--edit` で現在のファイルを差分を見ながら手修正、`--mine` で適用後の手修正分、`--changes` で変更概要、`--browser` でブラウザ強制 |
91
101
  | `petari list` | 履歴の一覧と合計サイズ |
92
102
  | `petari protocol` | AI への規約文を標準出力に出す (slnmix / repomix 連携用) |
93
- | `petari init` | プロジェクト初回セットアップ (`--yes` で全提案に同意) |
103
+ | `petari init` | プロジェクト初回セットアップ (`--yes` で全提案に同意。`--no-repomix` で repomix 連携を省く。slnmix 利用者は `npx slnmix init` が代わりに呼ぶ) |
94
104
 
95
105
  主なオプション: `--dry-run` (検証と差分プレビューのみ) / `--partial` (成功分のみ適用) /
96
106
  `--root <dir>` / `--yes` / `--clip-report` (失敗レポートをクリップボードへ。v0.5.0 から既定で自動コピーされるため、config で無効化した場合の個別指定用) /
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { join, dirname } from "node:path";
5
5
  import { err as errLine } from "./infra/term.js";
6
- const SUBCOMMANDS = ["undo", "show", "list", "protocol", "init"];
6
+ const SUBCOMMANDS = ["undo", "show", "list", "protocol", "init", "watch"];
7
7
  function version() {
8
8
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
9
9
  return JSON.parse(readFileSync(pkgPath, "utf8")).version;
@@ -12,11 +12,14 @@ const HELP = `petari — paste AI chat patches onto your codebase
12
12
 
13
13
  Usage:
14
14
  petari [path] changes.md を適用 (省略時: Downloads から自動検出)
15
+ petari watch Downloads を見張り、届いた changes.md を確認なしで適用
16
+ (Ctrl+C で終了)
15
17
  petari undo [ID] 履歴を巻き戻す (ID 省略時: 直近)
16
18
  petari show [ID] 履歴の差分を表示 (VS Code / ブラウザ)。--edit で手修正
17
19
  petari list 履歴の一覧を表示
18
20
  petari protocol AI への規約文を標準出力に出す
19
- petari init プロジェクト初回セットアップ
21
+ petari init プロジェクト初回セットアップ (--yes で全提案に同意、
22
+ --no-repomix で repomix 連携を省く)
20
23
 
21
24
  Options:
22
25
  -h, --help ヘルプを表示
@@ -64,6 +67,10 @@ export async function main(argv) {
64
67
  const { initCommand } = await import("./commands/init.js");
65
68
  return initCommand(rest);
66
69
  }
70
+ case "watch": {
71
+ const { watchCommand } = await import("./commands/watch.js");
72
+ return watchCommand(rest);
73
+ }
67
74
  default:
68
75
  process.stderr.write(`petari: "${sub}" は未実装です\n`);
69
76
  return 1;
@@ -73,7 +73,7 @@ function printAlreadyApplied(outcomes) {
73
73
  }
74
74
  }
75
75
  }
76
- export async function applyCommand(argv) {
76
+ export async function applyCommand(argv, detected) {
77
77
  const { values, positionals } = parseArgs({
78
78
  args: argv,
79
79
  options: {
@@ -119,7 +119,17 @@ export async function applyCommand(argv) {
119
119
  let changesText;
120
120
  let source;
121
121
  const inputPath = positionals[0];
122
- if (inputPath !== undefined) {
122
+ if (detected !== undefined) {
123
+ try {
124
+ changesText = stripBom(readFileSync(detected.path, "utf8"));
125
+ }
126
+ catch {
127
+ err(`petari: ファイルを読めません: ${detected.path}`);
128
+ return 1;
129
+ }
130
+ source = { type: detected.origin, path: detected.path };
131
+ }
132
+ else if (inputPath !== undefined) {
123
133
  const abs = resolve(process.cwd(), inputPath);
124
134
  try {
125
135
  changesText = stripBom(readFileSync(abs, "utf8"));
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * petari init — プロジェクト初回セットアップの一括実行 (§4.6)。
3
3
  * 対話形式 (--yes で全提案に自動同意)。既存ファイルは上書きせず確認する。
4
+ * --no-repomix は repomix 連携 (手順 3) を丸ごと省く。slnmix の `slnmix init` が
5
+ * `--yes --no-repomix` で呼ぶ (slnmix 利用者に repomix.config.json は要らないため)。
4
6
  */
5
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
8
  import { join } from "node:path";
@@ -24,6 +26,7 @@ export async function initCommand(argv) {
24
26
  options: {
25
27
  root: { type: "string" },
26
28
  yes: { type: "boolean", short: "y", default: false },
29
+ "no-repomix": { type: "boolean", default: false },
27
30
  },
28
31
  });
29
32
  const root = findProjectRoot(process.cwd(), values.root);
@@ -57,7 +60,10 @@ export async function initCommand(argv) {
57
60
  }
58
61
  // 3. repomix 連携 (§4.6-3)
59
62
  const repomixPath = join(root, "repomix.config.json");
60
- if (existsSync(repomixPath)) {
63
+ if (values["no-repomix"]) {
64
+ out("3. repomix 連携は省略します (--no-repomix)");
65
+ }
66
+ else if (existsSync(repomixPath)) {
61
67
  let parsed;
62
68
  try {
63
69
  parsed = JSON.parse(readFileSync(repomixPath, "utf8"));
@@ -114,6 +120,10 @@ export async function initCommand(argv) {
114
120
  out(" 作成しました: .gitignore");
115
121
  }
116
122
  out("");
123
+ if (values["no-repomix"]) {
124
+ out("セットアップ完了。");
125
+ return 0;
126
+ }
117
127
  out("セットアップ完了。repomix 実行で規約文入りのコンテキストが生成されます。");
118
128
  out("slnmix (v0.7.0+) は同じディレクトリの protocol.md を出力末尾に自動連結します。");
119
129
  return 0;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * petari watch — Downloads とプロジェクト直下を監視し、届いた changes*.md を自動で適用する (§4.7)。
3
+ * 新着 1 件ごとに applyCommand へ確認なしで渡す。検証・失敗レポートの自動コピー・
4
+ * 二重適用の防止・成功後の履歴への移動は `petari` (引数なし) と同じ。Ctrl+C で終了する。
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+ import { parseArgs } from "node:util";
9
+ import { hasChangesHeading } from "../core/parser.js";
10
+ import { initialWatchState, observeWatch } from "../core/watch.js";
11
+ import { loadConfig } from "../infra/config.js";
12
+ import { listChangesFiles, resolveDownloadsDir } from "../infra/downloads.js";
13
+ import { findProjectRoot } from "../infra/root.js";
14
+ import { err, out } from "../infra/term.js";
15
+ import { applyCommand } from "./apply.js";
16
+ const DEFAULT_INTERVAL_MS = 1000;
17
+ export async function watchCommand(argv, options = {}) {
18
+ const { values } = parseArgs({
19
+ args: argv,
20
+ options: {
21
+ root: { type: "string" },
22
+ "dry-run": { type: "boolean", default: false },
23
+ "no-vbproj": { type: "boolean", default: false },
24
+ },
25
+ allowPositionals: false,
26
+ });
27
+ const root = findProjectRoot(process.cwd(), values.root);
28
+ const config = loadConfig(root);
29
+ const downloadsDir = await resolveDownloadsDir(config.downloadsDir);
30
+ const list = () => {
31
+ const seen = new Set();
32
+ return [
33
+ ...listChangesFiles(downloadsDir).map((f) => ({ ...f, origin: "downloads" })),
34
+ ...listChangesFiles(root).map((f) => ({ ...f, origin: "project-root" })),
35
+ ].filter((f) => !seen.has(f.path) && (seen.add(f.path), true));
36
+ };
37
+ const stop = new AbortController();
38
+ const onSigint = () => stop.abort();
39
+ process.once("SIGINT", onSigint);
40
+ const onAbort = () => stop.abort();
41
+ options.signal?.addEventListener("abort", onAbort, { once: true });
42
+ if (options.signal?.aborted === true)
43
+ stop.abort();
44
+ const applyArgs = ["--root", root, "--yes"];
45
+ if (values["dry-run"])
46
+ applyArgs.push("--dry-run");
47
+ if (values["no-vbproj"])
48
+ applyArgs.push("--no-vbproj");
49
+ try {
50
+ // 開始時に既にあるファイルは対象外 (古い changes.md を黙って当てない)
51
+ const state = initialWatchState(list());
52
+ out("changes*.md の到着を待っています。届いたら確認なしで適用します (Ctrl+C で終了)");
53
+ out(` Downloads: ${downloadsDir}`);
54
+ out(` プロジェクト直下: ${root}`);
55
+ if (values["dry-run"])
56
+ out(" --dry-run: 検証と差分プレビューだけで、書き込みはしません");
57
+ if (state.settled.size > 0) {
58
+ out(` (既にある ${state.settled.size} 件は対象外です。それを当てるときは petari を実行してください)`);
59
+ }
60
+ const interval = options.intervalMs ?? DEFAULT_INTERVAL_MS;
61
+ while (!stop.signal.aborted) {
62
+ try {
63
+ await delay(interval, undefined, { signal: stop.signal });
64
+ }
65
+ catch {
66
+ break; // 中断
67
+ }
68
+ for (const f of observeWatch(state, list())) {
69
+ if (stop.signal.aborted)
70
+ break;
71
+ out("");
72
+ out(`━━ 新しい changes.md: ${f.path}`);
73
+ let text;
74
+ try {
75
+ text = readFileSync(f.path, "utf8");
76
+ }
77
+ catch {
78
+ err(`petari: ファイルを読めません: ${f.path}`);
79
+ continue;
80
+ }
81
+ // 変更履歴の CHANGES.md 等を当てに行かない (失敗レポートでクリップボードを上書きしないため)
82
+ if (!hasChangesHeading(text.replace(/^/, ""))) {
83
+ out(" ## CHANGES の見出しがないため、changes.md ではないと判断して適用しません");
84
+ out(" (AI の返答なら `petari <パス>` で実行すると失敗の理由を確認できます)");
85
+ continue;
86
+ }
87
+ try {
88
+ await applyCommand(applyArgs, { path: f.path, origin: f.origin });
89
+ }
90
+ catch (e) {
91
+ // 1 件の失敗で監視ごと止めない
92
+ err(`petari: ${e instanceof Error ? e.message : String(e)}`);
93
+ }
94
+ out("");
95
+ out("引き続き待っています (Ctrl+C で終了)");
96
+ }
97
+ }
98
+ }
99
+ finally {
100
+ process.removeListener("SIGINT", onSigint);
101
+ options.signal?.removeEventListener("abort", onAbort);
102
+ }
103
+ out("監視を終了しました。");
104
+ return 0;
105
+ }
@@ -28,6 +28,18 @@ const LENIENT_CHANGES_RE = /^#{1,6}\s*CHANGES$/;
28
28
  const LENIENT_FILE_ATTEMPT_RE = /^#{1,6}\s*FILE\s*:/;
29
29
  const LENIENT_FILE_RE = /^#{1,6}\s*FILE\s*:\s*(.+?)\s*\((replace|create|rewrite|delete)\)$/;
30
30
  const FENCE_RE = /^`{3,}[A-Za-z0-9_-]*$/;
31
+ /**
32
+ * CHANGES 見出し (寛容パースが受け付けるレベル違いを含む) があるか。
33
+ * petari watch が changes.md 以外の CHANGES.md 等 (変更履歴) を当てに行かないための判定
34
+ */
35
+ export function hasChangesHeading(text) {
36
+ return text
37
+ .split(/\r?\n/)
38
+ .some((raw) => {
39
+ const line = raw.trimEnd();
40
+ return line === CHANGES_HEADING || LENIENT_CHANGES_RE.test(line);
41
+ });
42
+ }
31
43
  /**
32
44
  * changes.md をパースする (§3)。
33
45
  *
@@ -0,0 +1,42 @@
1
+ /**
2
+ * petari watch の新着検出 (純粋ロジック。§4.7)。
3
+ * 監視先の changes*.md の一覧 (パス・更新時刻・サイズ) を毎回受け取り、
4
+ * 「監視開始後に現れた / 更新された、書き込みが落ち着いたファイル」を返す。
5
+ *
6
+ * - 開始時に既にあるファイルは対象外 (古い changes.md を黙って当てない)
7
+ * - 書き込み途中を読まないよう、同じ時刻・サイズを 2 回続けて観測してから渡す
8
+ * - 0 バイトは渡さない (Firefox は最終名の空ファイルを先に作り、完了時に置き換える)
9
+ * - 一度渡したファイルは、内容が変わる (時刻かサイズが変わる) まで再び渡さない
10
+ * (失敗した changes.md を毎回当て直してレポートでクリップボードを上書きしないため)
11
+ */
12
+ const stamp = (f) => `${f.mtimeMs}:${f.size}`;
13
+ export function initialWatchState(files) {
14
+ return { settled: new Map(files.map((f) => [f.path, stamp(f)])), pending: new Map() };
15
+ }
16
+ /**
17
+ * 1 回分の観測を反映し、今回渡すファイルを古い順に返す。state は書き換える。
18
+ * 一覧から消えたファイルは忘れる (同じ名前で再ダウンロードされたら新着として扱う)
19
+ */
20
+ export function observeWatch(state, files) {
21
+ const present = new Set(files.map((f) => f.path));
22
+ for (const path of [...state.settled.keys()])
23
+ if (!present.has(path))
24
+ state.settled.delete(path);
25
+ for (const path of [...state.pending.keys()])
26
+ if (!present.has(path))
27
+ state.pending.delete(path);
28
+ const ready = [];
29
+ for (const f of files) {
30
+ const s = stamp(f);
31
+ if (state.settled.get(f.path) === s)
32
+ continue;
33
+ if (f.size > 0 && state.pending.get(f.path) === s) {
34
+ state.pending.delete(f.path);
35
+ state.settled.set(f.path, s);
36
+ ready.push(f);
37
+ continue;
38
+ }
39
+ state.pending.set(f.path, s);
40
+ }
41
+ return ready.sort((a, b) => a.mtimeMs - b.mtimeMs || a.path.localeCompare(b.path));
42
+ }
@@ -50,6 +50,8 @@ export async function resolveDownloadsDir(configured) {
50
50
  }
51
51
  return join(homedir(), "Downloads");
52
52
  }
53
+ /** 自動検出・監視の対象になるファイル名 (§4.1)。ブラウザが付ける "changes (1).md" 等も含む */
54
+ const CHANGES_FILE_NAME = /^changes.*\.md$/i;
53
55
  /**
54
56
  * changes*.md のうち更新が maxAgeMinutes 以内のものを新しい順に返す (§4.1)。
55
57
  * 古いファイルの誤適用を防ぐため、既定 30 分より古いものは対象外。
@@ -64,7 +66,7 @@ export function findRecentChangesFiles(dir, now, maxAgeMinutes = 30) {
64
66
  }
65
67
  const found = [];
66
68
  for (const name of names) {
67
- if (!/^changes.*\.md$/i.test(name))
69
+ if (!CHANGES_FILE_NAME.test(name))
68
70
  continue;
69
71
  const path = join(dir, name);
70
72
  try {
@@ -81,3 +83,28 @@ export function findRecentChangesFiles(dir, now, maxAgeMinutes = 30) {
81
83
  }
82
84
  return found.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
83
85
  }
86
+ /** petari watch 用: changes*.md を時刻・サイズ付きで列挙する (時刻での絞り込みはしない・§4.7) */
87
+ export function listChangesFiles(dir) {
88
+ let names;
89
+ try {
90
+ names = readdirSync(dir);
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ const found = [];
96
+ for (const name of names) {
97
+ if (!CHANGES_FILE_NAME.test(name))
98
+ continue;
99
+ const path = join(dir, name);
100
+ try {
101
+ const st = statSync(path);
102
+ if (st.isFile())
103
+ found.push({ path, mtimeMs: st.mtimeMs, size: st.size });
104
+ }
105
+ catch {
106
+ // ダウンロード直後の一時的なロック等で読めないファイルは次回に回す
107
+ }
108
+ }
109
+ return found;
110
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "petari",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "petari — paste AI chat patches onto your codebase",
5
5
  "keywords": [
6
6
  "cli",