petari 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yuki Sakakibara
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # petari
2
+
3
+ **petari — paste AI chat patches onto your codebase**
4
+ **petari — AIチャットの返答を、コードベースにぺたり。**
5
+
6
+ チャットベース AI (M365 Copilot Chat 等) の返答を、ローカルのコードベースへ安全に一括適用する CLI ツールです。
7
+ AI に規約形式の変更指示ファイル (changes.md) を出力させ、petari がパース・検証して適用します。
8
+ レビューは Git の未コミット差分として VS Code で行い、適用の履歴 (before/after/指示原本) は
9
+ `.petari/history/` に自動保存されます。
10
+
11
+ ## インストール
12
+
13
+ ```sh
14
+ pnpm add -g petari # または npx petari
15
+ ```
16
+
17
+ ## 使い方
18
+
19
+ ```sh
20
+ petari init # プロジェクト初回セットアップ (protocol.md / 設定 / repomix 連携 / .gitignore)
21
+ repomix # 規約文入りのコンテキストを生成して AI チャットに渡す
22
+ # → AI から changes.md をダウンロード
23
+ petari # Downloads の changes.md を自動検出して適用
24
+ git diff # VS Code で差分レビュー・手修正
25
+ petari undo # 直近の適用を巻き戻す
26
+ ```
27
+
28
+ ### コマンド
29
+
30
+ | コマンド | 説明 |
31
+ |---|---|
32
+ | `petari [path]` | changes.md を適用。省略時は Downloads から自動検出 (直近 30 分)。`--clip` でクリップボードから |
33
+ | `petari undo [ID]` | 履歴を巻き戻す (手修正がある場合は警告) |
34
+ | `petari show <ID>` | VS Code の差分ビューで履歴を表示。`--mine` で適用後の手修正分、`--changes` で変更概要 |
35
+ | `petari list` | 履歴の一覧と合計サイズ |
36
+ | `petari protocol` | AI への規約文を標準出力に出す (slnmix / repomix 連携用) |
37
+ | `petari init` | プロジェクト初回セットアップ (`--yes` で全提案に同意) |
38
+
39
+ 主なオプション: `--dry-run` (検証と差分プレビューのみ) / `--partial` (成功分のみ適用) /
40
+ `--root <dir>` / `--yes` / `--clip-report` (失敗レポートをクリップボードへ)
41
+
42
+ ## 特徴
43
+
44
+ - **all-or-nothing**: 全ブロックを事前検証し、1 つでも失敗があれば何も書き込まない
45
+ - **エンコーディング保全**: Shift_JIS / UTF-8 (BOM 有無)・CRLF / LF・末尾改行を完全維持。
46
+ 変更していない行は元のバイト列をそのまま書き戻す (レガシー VB.NET 資産でも安全)
47
+ - **失敗レポート**: そのまま AI チャットに貼り返せる形式で出力。再依頼が 1 コピペで済む
48
+ - **実行時依存ゼロ**: サプライチェーン対策として外部パッケージに依存しない
49
+ (Shift_JIS 変換テーブルも Node 組み込み ICU から自動生成して同梱)
50
+
51
+ ## セキュリティ設計
52
+
53
+ 社内利用の審査 (情報システム部門のレビュー等) を想定した要点です。
54
+
55
+ ### 通信・依存関係
56
+
57
+ - **実行時のネットワーク通信は一切行いません**。AI との通信機能はなく、入力はローカルファイル
58
+ またはクリップボードのみ。テレメトリ・自動更新もありません
59
+ - **実行時依存パッケージはゼロ**です。npm のインストールスクリプト (postinstall 等) も
60
+ 使用しません。Shift_JIS 変換テーブルは Node.js 組み込みの ICU から生成しリポジトリに
61
+ 同梱しています (外部データの取り込みなし)
62
+ - 子プロセスの起動はすべて `execFile`/`spawn` の配列引数で行い、シェルを経由しません
63
+ (コマンドインジェクション不成立)。起動するのは `git` / `reg` / `powershell` /
64
+ `pbcopy`・`pbpaste` / VS Code (`code`) のみです
65
+
66
+ ### 入力の取り扱い (信頼境界)
67
+
68
+ changes.md (AI 出力)・クリップボード・リポジトリに同梱され得る `.petari/` 内のファイルは
69
+ **すべて非信頼入力**として扱います。
70
+
71
+ - 書き込み先パスは**プロジェクトルート相対のみ**。絶対パス・UNC・ドライブレター・`..`・`~`・
72
+ `:` (NTFS 代替データストリーム)・Windows 予約デバイス名 (CON/NUL/COM1 等、末尾ドット・
73
+ 空白による偽装を含む) を拒否します
74
+ - シンボリックリンク自体の書き換えに加え、**symlink ディレクトリ経由でルート外へ出るパスも
75
+ realpath 検証で拒否**します
76
+ - パス検証は apply だけでなく undo / show (履歴 manifest 由来のパス) にも適用します
77
+ - 端末出力は制御文字 (ANSI エスケープ・双方向制御文字 = Trojan Source) を除去してから
78
+ 表示します (表示偽装対策)
79
+ - 差分ビューアのコマンド (`vscodeCommand` 設定) は PATH 上のコマンド名か絶対パスのみ許可し、
80
+ リポジトリ内スクリプトを指せる相対パスと UNC パスを拒否します
81
+
82
+ ### 適用の安全弁と監査性
83
+
84
+ - 全変更を**事前検証してから書き込む all-or-nothing** (1 件でも失敗すれば何も書かない)。
85
+ `--dry-run` で書き込みなしの事前確認が可能
86
+ - すべての適用は `.petari/history/` に **適用前後のファイル・指示原本・SHA-256 付き
87
+ manifest** として記録され、`petari undo` で巻き戻せます (監査証跡)
88
+ - Git 作業ツリーに未コミット変更がある場合は警告し、適用前に確認を求めます
89
+
90
+ ### 既知の検知可能性・残リスク
91
+
92
+ - Windows でクリップボード機能 (`--clip` / `--clip-report`) を使うと
93
+ `node.exe → powershell.exe (Get-Clipboard / Set-Clipboard)` の親子プロセスが発生し、
94
+ **EDR がログ・アラート対象にする可能性があります** (ユーザー起点の単発実行です。
95
+ PowerShell が制限された環境ではクリップボード機能のみ失敗し、ファイル入力は動作します)
96
+ - 検証と書き込みの間の TOCTOU (ファイル差し替え) は検出しません (単独利用の CLI として許容)
97
+ - 履歴には対象ファイルの複製が平文で保存されます。`.petari/` は `.gitignore` 推奨
98
+ (init が自動提案) で、リポジトリには含まれません
99
+
100
+ ## 開発
101
+
102
+ ```sh
103
+ pnpm install
104
+ pnpm typecheck # tsc --noEmit
105
+ pnpm test # vitest
106
+ pnpm build # dist/ へビルド
107
+ ```
108
+
109
+ 設計方針・規約は [CLAUDE.md](CLAUDE.md)、仕様の正本は [requirements.md](requirements.md) を参照。
110
+
111
+ ## License
112
+
113
+ MIT
@@ -0,0 +1,73 @@
1
+ /**
2
+ * AI への規約文 (§4.5)。single source はこのファイル。
3
+ * slnmix は `petari protocol` の出力を取り込み、repomix は
4
+ * `petari protocol > protocol.md` + instructionFilePath で読み込む。
5
+ * 内容を変更したら PROTOCOL_VERSION を上げること。
6
+ */
7
+ export const PROTOCOL_VERSION = 1;
8
+ export const PROTOCOL_TEXT = `<!-- petari protocol v${PROTOCOL_VERSION} -->
9
+ # コード変更の出力規約 (changes.md)
10
+
11
+ コードの変更を提案するときは、必ず以下の規約に従った Markdown ファイルを 1 つ出力してください。
12
+ ファイル名は changes.md とし、ダウンロードできる形で提供してください。
13
+ このファイルはツールが機械的にパースしてローカルのコードベースへ適用します。
14
+ 規約から外れた出力は適用に失敗します。
15
+
16
+ ## 全体構造
17
+
18
+ 出力は必ず「## CHANGES」の行から始めます。
19
+
20
+ \`\`\`
21
+ ## CHANGES
22
+
23
+ (変更概要 — 必須。何を・なぜ変えるかを簡潔に)
24
+
25
+ 影響ファイル:
26
+ - src/utils/date.ts
27
+ - src/components/New.tsx
28
+
29
+ (処理フローが変わる場合のみ、Mermaid 図を付ける — 任意)
30
+
31
+ ### FILE: src/utils/date.ts (replace)
32
+ <<<<<<< SEARCH
33
+ old code
34
+ =======
35
+ new code
36
+ >>>>>>> REPLACE
37
+
38
+ ### FILE: src/components/New.tsx (create)
39
+ <<<<<<< CONTENT
40
+ (ファイル全文)
41
+ >>>>>>> END
42
+
43
+ ### FILE: src/legacy/Old.vb (rewrite)
44
+ <<<<<<< CONTENT
45
+ (ファイル全文)
46
+ >>>>>>> END
47
+
48
+ ### FILE: src/legacy/Unused.vb (delete)
49
+ \`\`\`
50
+
51
+ ## 操作種別
52
+
53
+ - replace: 部分変更。SEARCH/REPLACE ブロックを使う。1 ファイルに複数ブロック可
54
+ - create: 新規ファイル作成。CONTENT ブロックに全文を書く。既存ファイルには使わない
55
+ - rewrite: 既存ファイルの全文置き換え。**ファイルの過半が変わる場合は replace ではなく rewrite を使う**
56
+ - delete: ファイル削除。ブロック本文は書かない
57
+ - ファイル名変更 (rename) は「旧パスの delete + 新パスの create」で表現する
58
+
59
+ ## 厳守事項
60
+
61
+ 1. マーカー行 (\`### FILE:\` \`<<<<<<< SEARCH\` \`=======\` \`>>>>>>> REPLACE\` \`<<<<<<< CONTENT\` \`>>>>>>> END\`) は
62
+ 必ず行頭から書き、前後に他の文字を付けない
63
+ 2. SEARCH ブロックの内容は、**現在のファイルから一字一句そのまま** (インデント・空白を含めて) コピーする。
64
+ 記憶に頼って書き換えたり要約したりしない
65
+ 3. SEARCH ブロックには **ファイル内で一意に特定できる範囲** を含める
66
+ (同じコードが複数箇所にある場合は、前後の行を含めて一意にする)
67
+ 4. パスはプロジェクトルートからの相対パスを / 区切りで書く。絶対パスや .. は使わない
68
+ 5. 同じファイルへの変更は 1 つの FILE セクションにまとめる (セクションを重複させない)
69
+ 6. FILE セクションの間に説明文を書かない。説明はすべて冒頭の CHANGES セクションに書く
70
+ 7. 対象ファイルが日本語 Shift_JIS の場合、絵文字など Shift_JIS で表現できない文字を
71
+ コード中に入れない
72
+ 8. changes.md 以外の出力 (前置き・後書き) は最小限にする
73
+ `;
package/dist/cli.js ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { join, dirname } from "node:path";
5
+ import { err as errLine } from "./infra/term.js";
6
+ const SUBCOMMANDS = ["undo", "show", "list", "protocol", "init"];
7
+ function version() {
8
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
9
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version;
10
+ }
11
+ const HELP = `petari — paste AI chat patches onto your codebase
12
+
13
+ Usage:
14
+ petari [path] changes.md を適用 (省略時: Downloads から自動検出)
15
+ petari undo [ID] 履歴を巻き戻す (ID 省略時: 直近)
16
+ petari show <ID> 履歴の差分を VS Code で表示
17
+ petari list 履歴の一覧を表示
18
+ petari protocol AI への規約文を標準出力に出す
19
+ petari init プロジェクト初回セットアップ
20
+
21
+ Options:
22
+ -h, --help ヘルプを表示
23
+ -v, --version バージョンを表示
24
+ `;
25
+ export async function main(argv) {
26
+ if (argv.includes("-h") || argv.includes("--help")) {
27
+ process.stdout.write(HELP);
28
+ return 0;
29
+ }
30
+ if (argv.includes("-v") || argv.includes("--version")) {
31
+ process.stdout.write(version() + "\n");
32
+ return 0;
33
+ }
34
+ const first = argv[0];
35
+ const sub = first !== undefined && SUBCOMMANDS.includes(first)
36
+ ? first
37
+ : "apply";
38
+ const rest = sub === "apply" ? argv : argv.slice(1);
39
+ switch (sub) {
40
+ case "apply": {
41
+ const { applyCommand } = await import("./commands/apply.js");
42
+ return applyCommand(rest);
43
+ }
44
+ case "undo": {
45
+ const { undoCommand } = await import("./commands/undo.js");
46
+ return undoCommand(rest);
47
+ }
48
+ case "show": {
49
+ const { showCommand } = await import("./commands/show.js");
50
+ return showCommand(rest);
51
+ }
52
+ case "list": {
53
+ const { listCommand } = await import("./commands/list.js");
54
+ return listCommand(rest);
55
+ }
56
+ case "protocol": {
57
+ const { protocolCommand } = await import("./commands/protocol.js");
58
+ return protocolCommand(rest);
59
+ }
60
+ case "init": {
61
+ const { initCommand } = await import("./commands/init.js");
62
+ return initCommand(rest);
63
+ }
64
+ default:
65
+ process.stderr.write(`petari: "${sub}" は未実装です\n`);
66
+ return 1;
67
+ }
68
+ }
69
+ const invokedDirectly = process.argv[1] !== undefined &&
70
+ import.meta.url === new URL(`file://${process.argv[1]}`).href;
71
+ if (invokedDirectly || process.argv[1]?.endsWith("cli.js") || process.argv[1]?.endsWith("cli.ts")) {
72
+ main(process.argv.slice(2)).then((code) => {
73
+ process.exitCode = code;
74
+ }, (err) => {
75
+ // エラーメッセージには changes.md 由来の文字が混入し得るためサニタイズして出力
76
+ errLine(`petari: ${err instanceof Error ? err.message : String(err)}`);
77
+ process.exitCode = 1;
78
+ });
79
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * petari [path] — 適用コマンド (§4.1)。
3
+ * 手順: パース → 全件ドライラン検証 → (失敗があれば何も書かずレポート) →
4
+ * before 保存 → 書き換え → after 保存 → サマリ表示。
5
+ */
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { join, resolve } from "node:path";
8
+ import { parseArgs } from "node:util";
9
+ import { invalidPathReason, planChangeSet, } from "../core/applier.js";
10
+ import { parseChanges } from "../core/parser.js";
11
+ import { buildFailureReport, buildParseErrorReport } from "../core/report.js";
12
+ import { readClipboard, writeClipboard } from "../infra/clipboard.js";
13
+ import { loadConfig } from "../infra/config.js";
14
+ import { findRecentChangesFiles, resolveDownloadsDir, } from "../infra/downloads.js";
15
+ import { deleteFile, isInsideRoot, readFileState, sha256, writeBytes } from "../infra/files.js";
16
+ import { gitDirtyFiles } from "../infra/git.js";
17
+ import { err, out } from "../infra/term.js";
18
+ import { beginHistory, createHistoryId, finishHistory, pruneHistory, } from "../infra/history.js";
19
+ import { confirm } from "../infra/prompt.js";
20
+ import { findProjectRoot } from "../infra/root.js";
21
+ /** --partial 時: このファイルは書き込み対象か */
22
+ function isApplicable(o) {
23
+ if (o.failures.length === 0)
24
+ return true;
25
+ return o.change.op === "replace" && o.appliedBlocks.length > 0;
26
+ }
27
+ function opLabel(o) {
28
+ const c = o.change;
29
+ if (c.op === "replace")
30
+ return `replace ${c.path} — ${o.appliedBlocks.length}/${o.totalBlocks} ブロック`;
31
+ return `${c.op.padEnd(7)} ${c.path}`;
32
+ }
33
+ function printPreview(outcomes) {
34
+ for (const o of outcomes) {
35
+ out(` ${opLabel(o)}`);
36
+ for (const b of o.appliedBlocks) {
37
+ out(` @@ ${o.change.path}:${b.start + 1} (${b.stage})`);
38
+ for (const l of b.removed)
39
+ out(` - ${l}`);
40
+ for (const l of b.inserted)
41
+ out(` + ${l}`);
42
+ }
43
+ }
44
+ }
45
+ export async function applyCommand(argv) {
46
+ const { values, positionals } = parseArgs({
47
+ args: argv,
48
+ options: {
49
+ "dry-run": { type: "boolean", default: false },
50
+ partial: { type: "boolean", default: false },
51
+ root: { type: "string" },
52
+ yes: { type: "boolean", short: "y", default: false },
53
+ clip: { type: "boolean", default: false },
54
+ "clip-report": { type: "boolean", default: false },
55
+ },
56
+ allowPositionals: true,
57
+ });
58
+ const root = findProjectRoot(process.cwd(), values.root);
59
+ const config = loadConfig(root);
60
+ if (!existsSync(join(root, ".petari"))) {
61
+ out("ヒント: 初回は `petari init` を実行すると protocol.md や設定の雛形が整います");
62
+ }
63
+ // レポートを標準出力へ出し、--clip-report ならクリップボードにもコピーする (§7)
64
+ const emitReport = async (report) => {
65
+ out(report);
66
+ if (values["clip-report"]) {
67
+ try {
68
+ await writeClipboard(report);
69
+ out("(レポートをクリップボードにコピーしました)");
70
+ }
71
+ catch (e) {
72
+ err(`petari: クリップボードへのコピーに失敗: ${e instanceof Error ? e.message : String(e)}`);
73
+ }
74
+ }
75
+ };
76
+ const stripBom = (s) => s.replace(/^\uFEFF/, "");
77
+ // 入力ソースの優先順位 (§4.1): 引数 > --clip > Downloads 自動検出
78
+ let changesText;
79
+ let source;
80
+ const inputPath = positionals[0];
81
+ if (inputPath !== undefined) {
82
+ const abs = resolve(process.cwd(), inputPath);
83
+ try {
84
+ changesText = stripBom(readFileSync(abs, "utf8"));
85
+ }
86
+ catch {
87
+ err(`petari: ファイルを読めません: ${abs}`);
88
+ return 1;
89
+ }
90
+ source = { type: "file", path: abs };
91
+ }
92
+ else if (values.clip) {
93
+ try {
94
+ changesText = stripBom(await readClipboard());
95
+ }
96
+ catch (e) {
97
+ err(`petari: クリップボードを読めません: ${e instanceof Error ? e.message : String(e)}`);
98
+ return 1;
99
+ }
100
+ if (changesText.trim() === "") {
101
+ err("petari: クリップボードが空です");
102
+ return 1;
103
+ }
104
+ source = { type: "clipboard" };
105
+ }
106
+ else {
107
+ const downloadsDir = await resolveDownloadsDir(config.downloadsDir);
108
+ const candidates = findRecentChangesFiles(downloadsDir, new Date());
109
+ if (candidates.length === 0) {
110
+ err(`petari: ${downloadsDir} に直近 30 分以内の changes*.md が見つかりません`);
111
+ err(" ファイルパスを指定するか、--clip でクリップボードから読み込んでください");
112
+ return 1;
113
+ }
114
+ const newest = candidates[0];
115
+ out(`Downloads から検出: ${newest.path}`);
116
+ if (candidates.length > 1) {
117
+ out(` (他 ${candidates.length - 1} 件の候補があります。最新を使用します)`);
118
+ for (const c of candidates.slice(1))
119
+ out(` - ${c.path}`);
120
+ if (!values.yes && !(await confirm("このファイルを適用しますか?"))) {
121
+ out("中止しました。");
122
+ return 1;
123
+ }
124
+ }
125
+ try {
126
+ changesText = stripBom(readFileSync(newest.path, "utf8"));
127
+ }
128
+ catch {
129
+ err(`petari: ファイルを読めません: ${newest.path}`);
130
+ return 1;
131
+ }
132
+ source = { type: "downloads", path: newest.path };
133
+ }
134
+ // 1. パース (構文エラーは即時失敗・§4.1)
135
+ const { changeSet, issues } = parseChanges(changesText);
136
+ if (issues.length > 0) {
137
+ err("petari: changes.md の構文エラーです。何も書き込んでいません。\n");
138
+ await emitReport(buildParseErrorReport(issues));
139
+ return 1;
140
+ }
141
+ // 2. 全ブロックのドライラン検証
142
+ const states = new Map();
143
+ for (const f of changeSet.files) {
144
+ if (invalidPathReason(f.path) !== null) {
145
+ states.set(f.path, { exists: false, symlink: false, bytes: null });
146
+ continue;
147
+ }
148
+ const abs = join(root, f.path);
149
+ // symlink ディレクトリ経由でルート外に出るパスは検証段階で失敗させる (§9)
150
+ if (!isInsideRoot(root, abs)) {
151
+ states.set(f.path, { exists: false, symlink: false, bytes: null, escapesRoot: true });
152
+ continue;
153
+ }
154
+ states.set(f.path, readFileState(abs));
155
+ }
156
+ const plan = planChangeSet(changeSet, states, config.newFile);
157
+ // 3. 失敗があれば何も書き込まずレポート (§4.1, §7)
158
+ if (!plan.ok && !values.partial) {
159
+ err(`petari: 検証に失敗しました (${plan.failures.length} 件)。何も書き込んでいません。\n`);
160
+ await emitReport(buildFailureReport(plan.failures));
161
+ return 1;
162
+ }
163
+ const applicable = plan.outcomes.filter(isApplicable);
164
+ if (applicable.length === 0) {
165
+ err("petari: 適用できる変更がありません。\n");
166
+ await emitReport(buildFailureReport(plan.failures));
167
+ return 1;
168
+ }
169
+ if (values["dry-run"]) {
170
+ out(`dry-run: 適用予定 ${applicable.length} ファイル (書き込みなし)`);
171
+ printPreview(applicable);
172
+ if (plan.failures.length > 0) {
173
+ out("");
174
+ await emitReport(buildFailureReport(plan.failures));
175
+ }
176
+ return 0;
177
+ }
178
+ // 確認 (§4.1, §9)
179
+ out(`プロジェクトルート: ${root}`);
180
+ out(`適用予定 ${applicable.length} ファイル:`);
181
+ for (const o of applicable)
182
+ out(` ${opLabel(o)}`);
183
+ if (plan.failures.length > 0) {
184
+ out(` (検証失敗 ${plan.failures.length} 件は --partial によりスキップ)`);
185
+ }
186
+ const dirty = await gitDirtyFiles(root);
187
+ if (dirty !== null && dirty.length > 0) {
188
+ out(`警告: Git 作業ツリーに未コミットの変更が ${dirty.length} 件あります`);
189
+ }
190
+ if (!values.yes) {
191
+ if (!(await confirm("適用しますか?"))) {
192
+ out("中止しました。");
193
+ return 1;
194
+ }
195
+ }
196
+ // 4. before 保存 → 書き換え → after 保存 (§4.1 手順 4, §5)
197
+ const id = createHistoryId(root, new Date());
198
+ const before = new Map();
199
+ for (const o of applicable) {
200
+ before.set(o.change.path, states.get(o.change.path)?.bytes ?? null);
201
+ }
202
+ beginHistory(root, id, changesText, before);
203
+ const after = new Map();
204
+ for (const o of applicable) {
205
+ const abs = join(root, o.change.path);
206
+ if (o.change.op === "delete") {
207
+ deleteFile(abs);
208
+ after.set(o.change.path, null);
209
+ }
210
+ else {
211
+ writeBytes(abs, o.afterBytes);
212
+ after.set(o.change.path, o.afterBytes);
213
+ }
214
+ }
215
+ const entries = plan.outcomes.map((o) => {
216
+ const applied = isApplicable(o);
217
+ const beforeBytes = states.get(o.change.path)?.bytes ?? null;
218
+ const afterBytes = applied ? (after.get(o.change.path) ?? null) : null;
219
+ const skipped = o.failures
220
+ .filter((f) => f.block !== undefined)
221
+ .map((f) => ({ index: f.block.index, reason: f.message }));
222
+ const entry = {
223
+ path: o.change.path,
224
+ op: o.change.op,
225
+ applied,
226
+ blocks: o.totalBlocks,
227
+ appliedBlocks: o.appliedBlocks.length,
228
+ beforeSha256: beforeBytes !== null ? sha256(beforeBytes) : null,
229
+ afterSha256: afterBytes !== null ? sha256(afterBytes) : null,
230
+ };
231
+ if (skipped.length > 0)
232
+ entry.skippedBlocks = skipped;
233
+ return entry;
234
+ });
235
+ const manifest = {
236
+ id,
237
+ appliedAt: new Date().toISOString(),
238
+ success: plan.ok,
239
+ partial: values.partial,
240
+ source,
241
+ files: entries,
242
+ };
243
+ finishHistory(root, id, manifest, after);
244
+ const pruned = pruneHistory(root, config.historyLimit);
245
+ if (pruned.length > 0) {
246
+ out(`古い履歴 ${pruned.length} 件を削除しました (historyLimit: ${config.historyLimit})`);
247
+ }
248
+ // 5. サマリ (§4.1 手順 5)
249
+ out("");
250
+ out(`適用しました (履歴 ID: ${id})`);
251
+ for (const o of applicable)
252
+ out(` ${opLabel(o)}`);
253
+ if (plan.failures.length > 0) {
254
+ out(`スキップした失敗 ${plan.failures.length} 件のレポート:`);
255
+ out("");
256
+ await emitReport(buildFailureReport(plan.failures));
257
+ }
258
+ // 6. Downloads 由来なら changes.md を履歴へ移動 (原本は history に保存済み・§4.1)
259
+ if (source.type === "downloads" && source.path !== undefined) {
260
+ try {
261
+ deleteFile(source.path);
262
+ out(`Downloads の ${source.path} を履歴へ移動しました`);
263
+ }
264
+ catch {
265
+ err(`petari: ${source.path} を削除できませんでした (履歴には保存済み)`);
266
+ }
267
+ }
268
+ out("");
269
+ out("git diff で差分を確認してください。巻き戻しは `petari undo` です。");
270
+ return 0;
271
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * petari init — プロジェクト初回セットアップの一括実行 (§4.6)。
3
+ * 対話形式 (--yes で全提案に自動同意)。既存ファイルは上書きせず確認する。
4
+ */
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { parseArgs } from "node:util";
8
+ import { PROTOCOL_TEXT } from "../assets/protocol.js";
9
+ import { confirm } from "../infra/prompt.js";
10
+ import { findProjectRoot } from "../infra/root.js";
11
+ import { err, out } from "../infra/term.js";
12
+ const CONFIG_TEMPLATE = `{
13
+ "downloadsDir": null,
14
+ "newFile": { "encoding": "utf8", "eol": "lf" },
15
+ "historyLimit": null,
16
+ "vscodeCommand": "code"
17
+ }
18
+ `;
19
+ export async function initCommand(argv) {
20
+ const { values } = parseArgs({
21
+ args: argv,
22
+ options: {
23
+ root: { type: "string" },
24
+ yes: { type: "boolean", short: "y", default: false },
25
+ },
26
+ });
27
+ const root = findProjectRoot(process.cwd(), values.root);
28
+ const ask = async (question) => values.yes ? true : confirm(question);
29
+ out(`プロジェクトルート: ${root}`);
30
+ // 1. .petari/config.json の雛形 (§4.6-1)
31
+ const configPath = join(root, ".petari", "config.json");
32
+ if (existsSync(configPath)) {
33
+ out(`1. ${configPath} は既にあります (変更しません)`);
34
+ }
35
+ else if (await ask("1. .petari/config.json の雛形を作成しますか?")) {
36
+ mkdirSync(join(root, ".petari"), { recursive: true });
37
+ writeFileSync(configPath, CONFIG_TEMPLATE, "utf8");
38
+ out(` 作成しました: ${configPath}`);
39
+ }
40
+ // 2. protocol.md (§4.6-2)
41
+ const protocolPath = join(root, "protocol.md");
42
+ if (existsSync(protocolPath)) {
43
+ const current = readFileSync(protocolPath, "utf8");
44
+ if (current === PROTOCOL_TEXT) {
45
+ out(`2. ${protocolPath} は最新です`);
46
+ }
47
+ else if (await ask("2. protocol.md の内容が規約文と異なります。更新しますか?")) {
48
+ writeFileSync(protocolPath, PROTOCOL_TEXT, "utf8");
49
+ out(` 更新しました: ${protocolPath}`);
50
+ }
51
+ }
52
+ else if (await ask("2. protocol.md (AI への規約文) をプロジェクト直下に作成しますか?")) {
53
+ writeFileSync(protocolPath, PROTOCOL_TEXT, "utf8");
54
+ out(` 作成しました: ${protocolPath}`);
55
+ }
56
+ // 3. repomix 連携 (§4.6-3)
57
+ const repomixPath = join(root, "repomix.config.json");
58
+ if (existsSync(repomixPath)) {
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(readFileSync(repomixPath, "utf8"));
62
+ }
63
+ catch {
64
+ err(`3. ${repomixPath} を JSON として読めないためスキップします`);
65
+ parsed = { output: { instructionFilePath: "(parse error)" } };
66
+ }
67
+ if (parsed.output?.instructionFilePath !== undefined) {
68
+ out(`3. repomix.config.json は instructionFilePath 設定済みです (変更しません)`);
69
+ }
70
+ else if (await ask('3. repomix.config.json に output.instructionFilePath: "protocol.md" を追記しますか?')) {
71
+ parsed.output = { ...(parsed.output ?? {}), instructionFilePath: "protocol.md" };
72
+ writeFileSync(repomixPath, JSON.stringify(parsed, null, 2) + "\n", "utf8");
73
+ out(` 追記しました: ${repomixPath}`);
74
+ }
75
+ }
76
+ else if (await ask("3. repomix 連携用の repomix.config.json (最小構成) を作成しますか?")) {
77
+ writeFileSync(repomixPath, JSON.stringify({ output: { instructionFilePath: "protocol.md" } }, null, 2) + "\n", "utf8");
78
+ out(` 作成しました: ${repomixPath}`);
79
+ }
80
+ // 4. .gitignore に .petari/ (§4.6-4, §5)
81
+ const gitignorePath = join(root, ".gitignore");
82
+ if (existsSync(gitignorePath)) {
83
+ const lines = readFileSync(gitignorePath, "utf8").split(/\r?\n/);
84
+ if (lines.some((l) => l.trim() === ".petari/" || l.trim() === ".petari")) {
85
+ out("4. .gitignore は .petari/ を含んでいます");
86
+ }
87
+ else if (await ask("4. .gitignore に .petari/ を追記しますか?")) {
88
+ const text = readFileSync(gitignorePath, "utf8");
89
+ writeFileSync(gitignorePath, text + (text.endsWith("\n") || text === "" ? "" : "\n") + ".petari/\n", "utf8");
90
+ out(" 追記しました: .gitignore");
91
+ }
92
+ }
93
+ else if (await ask("4. .gitignore を作成して .petari/ を追記しますか?")) {
94
+ writeFileSync(gitignorePath, ".petari/\n", "utf8");
95
+ out(" 作成しました: .gitignore");
96
+ }
97
+ out("");
98
+ out("セットアップ完了。repomix 実行で規約文入りのコンテキストが生成されます。");
99
+ out("slnmix を使う場合は、出力に protocol.md を連結してください (petari protocol でも出力できます)。");
100
+ return 0;
101
+ }