@shinaps/skills 0.11.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.
Files changed (53) hide show
  1. package/.agents/plugins/marketplace.json +8 -6
  2. package/README.md +51 -5
  3. package/dist/bin/skills.js +15 -0
  4. package/dist/lib/auto-update.js +245 -0
  5. package/dist/lib/fs.js +49 -0
  6. package/dist/lib/install.js +204 -0
  7. package/dist/lib/layout.js +50 -0
  8. package/dist/lib/lock.js +109 -0
  9. package/dist/lib/transaction.js +115 -0
  10. package/package.json +35 -7
  11. package/plugins/{shinaps → shinaps-claude}/.claude-plugin/plugin.json +1 -1
  12. package/plugins/shinaps-codex/.codex-plugin/plugin.json +1 -1
  13. package/bin/skills.mjs +0 -9
  14. package/lib/install.mjs +0 -116
  15. /package/plugins/{shinaps → shinaps-claude}/agents/shinaps-architect.md +0 -0
  16. /package/plugins/{shinaps → shinaps-claude}/agents/shinaps-debug-validator.md +0 -0
  17. /package/plugins/{shinaps → shinaps-claude}/agents/shinaps-debugger.md +0 -0
  18. /package/plugins/{shinaps → shinaps-claude}/agents/shinaps-explorer.md +0 -0
  19. /package/plugins/{shinaps → shinaps-claude}/agents/shinaps-investigator.md +0 -0
  20. /package/plugins/{shinaps → shinaps-claude}/agents/shinaps-plan-reviewer.md +0 -0
  21. /package/plugins/{shinaps → shinaps-claude}/agents/shinaps-refine-agent.md +0 -0
  22. /package/plugins/{shinaps → shinaps-claude}/agents/shinaps-review-validator.md +0 -0
  23. /package/plugins/{shinaps → shinaps-claude}/agents/shinaps-reviewer.md +0 -0
  24. /package/plugins/{shinaps → shinaps-claude}/agents/shinaps-spec-writer.md +0 -0
  25. /package/plugins/{shinaps → shinaps-claude}/agents/shinaps-split-reviewer.md +0 -0
  26. /package/plugins/{shinaps → shinaps-claude}/agents/shinaps-survey-validator.md +0 -0
  27. /package/plugins/{shinaps → shinaps-claude}/agents/shinaps-tech-surveyor.md +0 -0
  28. /package/plugins/{shinaps → shinaps-claude}/skills/commit/SKILL.md +0 -0
  29. /package/plugins/{shinaps → shinaps-claude}/skills/create-pr/SKILL.md +0 -0
  30. /package/plugins/{shinaps → shinaps-claude}/skills/debug/SKILL.md +0 -0
  31. /package/plugins/{shinaps → shinaps-claude}/skills/dev/SKILL.md +0 -0
  32. /package/plugins/{shinaps → shinaps-claude}/skills/explore/SKILL.md +0 -0
  33. /package/plugins/{shinaps → shinaps-claude}/skills/explore/agents/openai.yaml +0 -0
  34. /package/plugins/{shinaps → shinaps-claude}/skills/extract-rules/SKILL.md +0 -0
  35. /package/plugins/{shinaps → shinaps-claude}/skills/refine/SKILL.md +0 -0
  36. /package/plugins/{shinaps → shinaps-claude}/skills/release/SKILL.md +0 -0
  37. /package/plugins/{shinaps → shinaps-claude}/skills/review/SKILL.md +0 -0
  38. /package/plugins/{shinaps → shinaps-claude}/skills/simplify/SKILL.md +0 -0
  39. /package/plugins/{shinaps → shinaps-claude}/skills/spec/SKILL.md +0 -0
  40. /package/plugins/{shinaps → shinaps-claude}/skills/verify-ui/SKILL.md +0 -0
  41. /package/{codex → plugins/shinaps-codex}/agents/shinaps-architect.toml +0 -0
  42. /package/{codex → plugins/shinaps-codex}/agents/shinaps-debug-validator.toml +0 -0
  43. /package/{codex → plugins/shinaps-codex}/agents/shinaps-debugger.toml +0 -0
  44. /package/{codex → plugins/shinaps-codex}/agents/shinaps-explorer.toml +0 -0
  45. /package/{codex → plugins/shinaps-codex}/agents/shinaps-investigator.toml +0 -0
  46. /package/{codex → plugins/shinaps-codex}/agents/shinaps-plan-reviewer.toml +0 -0
  47. /package/{codex → plugins/shinaps-codex}/agents/shinaps-refine-agent.toml +0 -0
  48. /package/{codex → plugins/shinaps-codex}/agents/shinaps-review-validator.toml +0 -0
  49. /package/{codex → plugins/shinaps-codex}/agents/shinaps-reviewer.toml +0 -0
  50. /package/{codex → plugins/shinaps-codex}/agents/shinaps-spec-writer.toml +0 -0
  51. /package/{codex → plugins/shinaps-codex}/agents/shinaps-split-reviewer.toml +0 -0
  52. /package/{codex → plugins/shinaps-codex}/agents/shinaps-survey-validator.toml +0 -0
  53. /package/{codex → plugins/shinaps-codex}/agents/shinaps-tech-surveyor.toml +0 -0
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "shinaps-skills-codex",
3
3
  "interface": { "displayName": "Shinaps AI for Codex" },
4
- "plugins": [{
5
- "name": "shinaps",
6
- "source": { "source": "local", "path": "./plugins/shinaps-codex" },
7
- "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" },
8
- "category": "Developer Tools"
9
- }]
4
+ "plugins": [
5
+ {
6
+ "name": "shinaps",
7
+ "source": { "source": "local", "path": "./plugins/shinaps-codex" },
8
+ "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" },
9
+ "category": "Developer Tools"
10
+ }
11
+ ]
10
12
  }
package/README.md CHANGED
@@ -46,9 +46,12 @@ Node.js 22以上と、`plugin marketplace add` / `plugin add` に対応したCod
46
46
  npx @shinaps/skills@latest install codex
47
47
  npx @shinaps/skills@latest update codex
48
48
  npx @shinaps/skills@latest uninstall codex
49
+ npx @shinaps/skills@latest recover codex
49
50
  ```
50
51
 
51
- ローカル開発では、このcheckoutから `node bin/skills.mjs install codex` のように同じ操作を実行できる。
52
+ `recover codex`は、中断した更新の記録が残っている場合に復元だけを行う。記録が無ければ何もしない。
53
+
54
+ ローカル開発では、このcheckoutから `node bin/skills.ts install codex` のように同じ操作を実行できる。Node.js 22.18以上なら型除去でTypeScriptをそのまま実行する (ビルド不要)。
52
55
 
53
56
  CLIはスキルをCodex pluginとして登録し、13個のカスタムエージェントを`${CODEX_HOME:-~/.codex}/agents/`へ配置する。新しいセッションで`shinaps:dev`、`shinaps:review`等が読み込まれる。エージェントのモデルと推論設定は親セッションから継承し、調査・レビュー担当は読み取り専用を既定にする。親セッションの実行時設定が優先される場合もあるため、権限の強制境界とは扱わない。
54
57
 
@@ -56,12 +59,41 @@ CLIはスキルをCodex pluginとして登録し、13個のカスタムエージ
56
59
 
57
60
  uninstallはこのCLIのplugin・marketplace・管理済みエージェントを取り除く。個人用スキルと、別途登録したMCP接続・認証情報は保持する。
58
61
 
62
+ ### 自動更新(Codex / macOS)
63
+
64
+ ```sh
65
+ npx @shinaps/skills@latest auto-update enable codex
66
+ npx @shinaps/skills@latest auto-update status codex
67
+ npx @shinaps/skills@latest auto-update check codex
68
+ npx @shinaps/skills@latest auto-update disable codex
69
+ ```
70
+
71
+ 初期状態は無効。導入済みの利用者が`enable`するとmacOSのユーザーLaunchAgentを登録し、ログイン中に1日1回(12:00)確認する。スリープ中に12:00を過ぎた場合は起床時に1回だけ実行する。電源が切れていた回は実行しない。確認のたびにnpmから`latest`のパッケージを取得して実行し、導入版より新しい安定版の場合だけ更新する。古い版への自動ダウングレードはしない。更新内容は新しいCodexセッションから反映される。
72
+
73
+ `enable`は通常のログインシェルから実行する。CodexやClaude Codeのセッション内からは実行しない。登録時のPATH、npmの場所、`CODEX_HOME`が記録され、以後の確認でもそのまま使われる。ツールの場所を変えた場合は再度`enable`する。npmの設定は`~/.npmrc`のものが使われる。
74
+
75
+ 登録直後に次の手順で動作を確認できる。`kickstart`はすぐに戻るので、npmの取得が終わるまで待ってから残りを実行する。
76
+
77
+ ```sh
78
+ launchctl kickstart -k gui/$(id -u)/io.shinaps.skills.update
79
+ launchctl print gui/$(id -u)/io.shinaps.skills.update | grep 'last exit code'
80
+ npx @shinaps/skills@latest auto-update status codex
81
+ ```
82
+
83
+ 更新前に新版を準備し、旧スキル・エージェント・管理情報を退避する。更新中のエラー時は旧内容とCodexのプラグイン登録を復元する。強制終了などで復元できなかった場合は、次の更新確認時または`recover codex`の実行時に、`~/.local/share/shinaps-ai/skills/transaction/`に残った記録から復元する。復元にも失敗した場合は同じ場所にバックアップを保持して停止するので、エラーの原因を直してから`recover codex`を実行する。導入後のユーザー編集や同時更新は上書きせず停止する。
84
+
85
+ `status`は設定と最終確認結果を表示する。失敗の詳細は`~/.local/share/shinaps-ai/skills/auto-update-error.log`、通常ログは同じ場所の`auto-update.log`に記録する。npmがパッケージを取得できなかった場合は最終確認結果は更新されず、エラーログだけに記録される。通知ポップアップは出さない。`disable`は予定実行を停止し、導入したスキルは保持する。`uninstall codex`は予定実行も停止する。
86
+
87
+ macOS以外では予定実行の登録に対応していないため手動更新を使う。
88
+
89
+ Claude Codeは標準のmarketplace自動更新機能を使う。`/plugin` のMarketplacesから対象marketplaceのauto-updateを有効にする。GitHubから自動取得するにはリモートmarketplaceを登録する必要があり、ローカルcheckout登録ではソースのgit pullまで自動実行しない。
90
+
59
91
  ## 開発と配布
60
92
 
61
- - Claude版: `plugins/shinaps/`、marketplaceは`.claude-plugin/marketplace.json`
93
+ - Claude版: `plugins/shinaps-claude/`、marketplaceは`.claude-plugin/marketplace.json`
62
94
  - Codex版: `plugins/shinaps-codex/`、marketplaceは`.agents/plugins/marketplace.json`
63
- - Codexカスタムエージェント: `codex/agents/`
64
- - 配布CLI: `bin/skills.mjs` / `lib/install.mjs`
95
+ - Codexカスタムエージェント: `plugins/shinaps-codex/agents/`
96
+ - 配布CLI: ソースは `bin/skills.ts` と `lib/*.ts`。`npm run build` (`tsc`) で `dist/` にJSを出力し、npmにはそれを配布する。Node.jsは`node_modules`配下の`.ts`に型除去を適用しないため、ソースのままは配布できない。checkoutでは型除去で直接実行するので、`enum`など型除去で消せない構文は使わない (`tsconfig.json` の `erasableSyntaxOnly`)
65
97
 
66
98
  品質基準とプロジェクト設定の方針は揃えつつ、クライアント固有の実行手順・エージェント形式は別々に管理する。Claude向けのエージェント呼び出しをCodexに読み替えさせる方式は使わない。スキルを追加・変更した場合は両版への適用可否を判断する。
67
99
 
@@ -73,6 +105,20 @@ npm pack --dry-run
73
105
 
74
106
  公開前にはnpmスコープ`@shinaps`の利用権限とtarball内容を確認する。CLIのバージョンと両pluginのバージョンを揃える。開発中の同一バージョンも`update codex`で再配置できる。
75
107
 
108
+ ### CIとnpm自動公開
109
+
110
+ `.github/workflows/publish.yml` はmainへのpush・PR・手動実行でテストと配布内容を検証する。通常のGitHub Releaseを公開すると、検証成功後に同じtagのパッケージをnpmへpublic公開する。prereleaseは公開しない。手動実行は検証専用で、npmへの公開は行わない。
111
+
112
+ リリース時は`package.json`と両plugin manifestのバージョンを揃えてコミットし、`v<version>` tagを対象にGitHub Releaseを公開する。tagとバージョンが不一致なら停止する。既存のnpmバージョンは再公開できないため、次の変更には新しいバージョンを使う。
113
+
114
+ npmの`@shinaps/skills`にGitHub ActionsのTrusted Publisherを登録する。GitHub ownerは`sh1n4ps`、repositoryは`shinaps-skills`、workflow filenameは`publish.yml`、direct publishを許可する。CIはGitHub-hosted Ubuntu・Node.js 24を使い、publish jobだけに`id-token: write`を付与する。`NPM_TOKEN` secretは不要。初回の信頼設定には本人認証が必要だが、その後のCI公開に毎回のセキュリティキー操作は不要。
115
+
116
+ ローカルで同じ配布検証を行うには`npm run verify:package`を使う。`RELEASE_TAG=v<version>`を付ければtag整合性も確認できる。npmのprovenanceは公開リポジトリからのTrusted Publishingが対象で、privateリポジトリでは生成されない。
117
+
118
+ ### コード整形
119
+
120
+ `npm ci` で開発依存を導入し、`npm run format` でBiomeによる整形を行う。`npm run check` は整形と型 (`tsc --noEmit`) を検証する。`npm pack` と `npm publish` は `prepack` で自動的に `npm run build` を実行する。`npm run verify:package` は配布物の一覧を比較したうえで、tarballを一時ディレクトリにinstallして起動できることを確認する。Biomeは整形用途で使用し、lintとimportの自動整理は無効。Markdown・TOMLのエージェント定義は整形対象外。
121
+
76
122
  ## Shinaps AIとの接続
77
123
 
78
124
  rule照合・抽出が必要な工程には `shinaps-ai` MCPを登録する。サービスURLは `https://app.shinaps.ai/mcp`。ClaudeとCodexそれぞれのMCP設定形式・ログイン手順に従う。開発用localhostはローカルサーバーを利用する場合だけ追加する。
@@ -114,7 +160,7 @@ codex mcp login shinaps-ai
114
160
  | `shinaps-debug-validator` | 仮説の実コード検証 (root-cause / contributing-factor / red-herring / needs-reproduction 分類) |
115
161
  | `shinaps-split-reviewer` | `shinaps:commit` の分割案を「単位の diff だけで妥当性を判断できるか」で批判 + 修正版最終分割計画 (worktree は read-only) |
116
162
 
117
- Claude版はMarkdown、Codex版はTOMLで役割を定義する。**subagent 内では shinaps MCP を直接叩かず、rule 全文は Skill 側から prompt 経由で渡す** 原則を全 system prompt に明記 (credit 最適化)。
163
+ Claude版はMarkdown、Codex版はTOMLで役割を定義する。**subagent 内では shinaps MCP を直接呼び出さず、rule 全文は Skill 側から prompt 経由で渡す** 原則を全 system prompt に明記する。MCP呼び出しの利用量が重複しないようにするため。
118
164
 
119
165
 
120
166
  ## 作業成果物
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../lib/install.js';
3
+ import { autoUpdate } from '../lib/auto-update.js';
4
+ import { errorMessage } from '../lib/fs.js';
5
+ const [command, ...rest] = process.argv.slice(2);
6
+ try {
7
+ if (command === 'auto-update')
8
+ await autoUpdate(rest);
9
+ else
10
+ await run(process.argv.slice(2));
11
+ }
12
+ catch (error) {
13
+ console.error(`Shinaps: ${errorMessage(error)}`);
14
+ process.exitCode = 1;
15
+ }
@@ -0,0 +1,245 @@
1
+ import { mkdir, writeFile, rm } from 'node:fs/promises';
2
+ import { join, dirname } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { execFileSync, spawnSync } from 'node:child_process';
5
+ import { withLock } from './lock.js';
6
+ import { install, openInstallation, recover } from './install.js';
7
+ import { errorMessage, readJson, readJsonIfExists } from './fs.js';
8
+ import { codexHomeMismatch, codexHomeOf, isRecordedCodexHome, managedRoot, manifestPath, payloadDir, statePath, } from './layout.js';
9
+ const actions = ['enable', 'disable', 'status', 'check'];
10
+ const isAction = (value) => actions.includes(value);
11
+ const label = 'io.shinaps.skills.update';
12
+ // StartInterval ではなく時刻指定にする。launchd はスリープ中に来た間隔の実行をスキップするが、時刻
13
+ // 指定ならスリープ中に過ぎた回を起床時に 1 回実行する (man launchd.plist)。12:00 は Mac が起きていて
14
+ // ログイン中である可能性が高い時刻として選んだ。
15
+ const schedule = { hour: 12, minute: 0 };
16
+ /**
17
+ * `shinaps-skills auto-update <enable|disable|status|check> codex`。
18
+ * @param args サブコマンドと対象。例: `['enable', 'codex']`。
19
+ * @param options 動作環境の指定。`auto-update check` とテストが使う。
20
+ */
21
+ export async function autoUpdate(args, options = {}) {
22
+ const [action, target, ...extra] = args;
23
+ if (!isAction(action) || target !== 'codex' || extra.length) {
24
+ throw new Error(`Unknown command: auto-update ${args.join(' ')}. Usage: shinaps-skills auto-update <enable|disable|status|check> codex`);
25
+ }
26
+ const home = options.home ?? homedir();
27
+ const root = managedRoot(home);
28
+ const context = {
29
+ home,
30
+ root,
31
+ configPath: join(root, 'auto-update.json'),
32
+ resultPath: join(root, 'auto-update-result.json'),
33
+ log: options.log ?? console.log,
34
+ options,
35
+ };
36
+ // enable / disable / check は同じロックで直列にする。check の実行中に disable が走らないようにするため。
37
+ const schedulerDir = join(root, 'scheduler');
38
+ switch (action) {
39
+ case 'status':
40
+ return status(context);
41
+ case 'check':
42
+ return withLock(schedulerDir, () => check(context));
43
+ case 'disable':
44
+ return withLock(schedulerDir, () => disable(context));
45
+ case 'enable':
46
+ return withLock(schedulerDir, () => enable(context));
47
+ }
48
+ }
49
+ async function status(context) {
50
+ const config = await readJsonIfExists(context.configPath);
51
+ const lastCheck = await readJsonIfExists(context.resultPath);
52
+ context.log(JSON.stringify({ enabled: config !== null, config, lastCheck }, null, 2));
53
+ }
54
+ async function check(context) {
55
+ // 設定はロックを取ってから読む。取得前に読むと、その間に完了した disable を見逃し、無効にしたあとで
56
+ // 更新が実行されるため。
57
+ const config = await readJsonIfExists(context.configPath);
58
+ if (!config) {
59
+ throw new Error('Auto-update is not enabled. Run "shinaps-skills auto-update enable codex" first.');
60
+ }
61
+ const result = { checkedAt: new Date().toISOString() };
62
+ try {
63
+ await updateIfNewer(context, config, result);
64
+ }
65
+ catch (error) {
66
+ result.status = 'failed';
67
+ await recordResult(context.resultPath, result, error);
68
+ throw error;
69
+ }
70
+ await recordResult(context.resultPath, result);
71
+ context.log(JSON.stringify(result));
72
+ }
73
+ async function updateIfNewer(context, config, result) {
74
+ const installation = openInstallation({ ...context.options, codexHome: config.codexHome });
75
+ await recover(installation);
76
+ const state = await readJson(statePath(context.root));
77
+ if (!isRecordedCodexHome(state.codexHome, config.codexHome)) {
78
+ throw new Error(`Shinaps is installed in CODEX_HOME=${state.codexHome}, but auto-update is configured for ${config.codexHome}. Run "CODEX_HOME=${state.codexHome} shinaps-skills auto-update enable codex".`);
79
+ }
80
+ // 導入済みの版は配布物の manifest にある。state.json には記録していない。
81
+ result.installed = await packagedVersion(payloadDir(context.root));
82
+ // launchd ジョブは `npm exec @shinaps/skills@latest` でこのコマンドを起動するので、このプロセスの
83
+ // パッケージが最新版になる。導入済みより新しければ、このパッケージを配布元にして update する。
84
+ result.available = await packagedVersion(installation.source);
85
+ const updated = newer(result.available, result.installed);
86
+ if (updated) {
87
+ await install(installation, 'update');
88
+ result.installed = result.available;
89
+ }
90
+ result.status = updated ? 'updated' : 'up-to-date';
91
+ }
92
+ const packagedVersion = async (directory) => (await readJson(join(directory, manifestPath))).version;
93
+ // 結果ファイルにはエラーメッセージを書かない。エラーは再 throw されて launchd のエラーログ
94
+ // (auto-update-error.log) に出力されるので、詳細はそちらで確認する。結果ファイルには status と、読み
95
+ // 取れた版だけを記録する。
96
+ async function recordResult(path, result, failure) {
97
+ try {
98
+ await writeFile(path, JSON.stringify(result, null, 2) + '\n');
99
+ }
100
+ catch (writeError) {
101
+ if (failure === undefined)
102
+ throw writeError;
103
+ throw new AggregateError([failure, writeError], `${errorMessage(failure)}; result not recorded: ${errorMessage(writeError)}`, { cause: failure });
104
+ }
105
+ }
106
+ async function disable(context) {
107
+ const config = await readJsonIfExists(context.configPath);
108
+ // OS の確認より前に判定する。uninstall はどの OS でも disable を呼び、自動更新を有効にしたことのない
109
+ // 導入でもそこで失敗してはならない。
110
+ if (!config) {
111
+ context.log('Auto-update is already disabled.');
112
+ return;
113
+ }
114
+ const { service, plistPath, launchctl } = launchd(context);
115
+ await launchctl(['bootout', service]);
116
+ await rm(plistPath, { force: true });
117
+ await rm(context.configPath);
118
+ context.log('Daily auto-update disabled. Installed skills and agents were preserved.');
119
+ }
120
+ async function enable(context) {
121
+ const { home, root, configPath, options, log } = context;
122
+ const config = await readJsonIfExists(configPath);
123
+ const { uid, service, plistPath, launchctl } = launchd(context);
124
+ const state = await readJsonIfExists(statePath(root));
125
+ if (!state) {
126
+ throw new Error('Shinaps is not installed for Codex. Run "shinaps-skills install codex" first.');
127
+ }
128
+ const codexHome = codexHomeOf(options, home);
129
+ if (!isRecordedCodexHome(state.codexHome, codexHome)) {
130
+ throw new Error(`${codexHomeMismatch(state.codexHome, codexHome)} Run again with CODEX_HOME=${state.codexHome}.`);
131
+ }
132
+ const nextConfig = {
133
+ codexHome,
134
+ // `status` で表示する。同じパスが ProgramArguments の先頭にも入る。
135
+ npm: options.npm ?? execFileSync('/usr/bin/which', ['npm'], { encoding: 'utf8' }).trim(),
136
+ };
137
+ // ジョブは固定のコピーではなく、`npm exec @latest` で毎回最新版の CLI を取得して check を実行する
138
+ // (npm はタグを毎回オンラインで解決する)。そのため CLI 自体を更新する仕組みは持たない。代わりに、
139
+ // このコマンド行と各ファイルの形式は版をまたいで保つ (layout.ts の冒頭を参照)。
140
+ const plistText = renderPlist({
141
+ programArguments: [
142
+ nextConfig.npm,
143
+ 'exec',
144
+ '--yes',
145
+ '--package',
146
+ '@shinaps/skills@latest',
147
+ '--',
148
+ 'shinaps-skills',
149
+ 'auto-update',
150
+ 'check',
151
+ 'codex',
152
+ ],
153
+ path: options.path ?? process.env.PATH ?? '',
154
+ root,
155
+ });
156
+ // 読み込み済みのラベルへの bootstrap は失敗するので、再 enable では先に今のジョブを外す。
157
+ if (config)
158
+ await launchctl(['bootout', service]);
159
+ await mkdir(dirname(plistPath), { recursive: true });
160
+ await writeFile(plistPath, plistText);
161
+ await writeFile(configPath, JSON.stringify(nextConfig, null, 2) + '\n');
162
+ try {
163
+ await launchctl(['bootstrap', `gui/${uid}`, plistPath]);
164
+ }
165
+ catch (error) {
166
+ try {
167
+ await rm(configPath, { force: true });
168
+ await rm(plistPath, { force: true });
169
+ }
170
+ catch (cleanup) {
171
+ throw new AggregateError([error, cleanup], `${errorMessage(error)}; cleanup failed: ${errorMessage(cleanup)}`, { cause: error });
172
+ }
173
+ throw error;
174
+ }
175
+ const time = `${schedule.hour}:${String(schedule.minute).padStart(2, '0')}`;
176
+ log(`Daily auto-update enabled: runs at ${time} while you are logged in (a run missed during sleep starts on wake).`);
177
+ log(`To test it now: launchctl kickstart -k ${service}`);
178
+ log(`Then check the result: shinaps-skills auto-update status codex (errors: ${join(root, 'auto-update-error.log')})`);
179
+ }
180
+ /** launchd の操作に必要な値。macOS 以外では throw する。 */
181
+ function launchd(context) {
182
+ const { options, home } = context;
183
+ if ((options.platform ?? process.platform) !== 'darwin') {
184
+ throw new Error('Auto-update is available only on macOS. Run "shinaps-skills update codex" to update manually.');
185
+ }
186
+ // macOS であることを確認済みなので getuid は存在する。
187
+ const uid = options.uid ?? process.getuid();
188
+ return {
189
+ uid,
190
+ service: `gui/${uid}/${label}`,
191
+ plistPath: join(home, 'Library/LaunchAgents', `${label}.plist`),
192
+ launchctl: options.launch ?? launchctlCommand,
193
+ };
194
+ }
195
+ const launchctlCommand = (argv) => {
196
+ const result = spawnSync('/bin/launchctl', argv, { encoding: 'utf8' });
197
+ if (result.error)
198
+ throw result.error;
199
+ // 読み込まれていないラベルの bootout は終了コード 3 (ESRCH。`launchctl error 3` で意味を確認できる)
200
+ // で終わる。disable と再 enable はジョブが読み込まれていなくても続けるので、この場合だけ成功として
201
+ // 扱う。終了コードの意味は文書化されていないため、ほかの失敗は区別せずにエラーにする。
202
+ if (result.status !== 0 && !(argv[0] === 'bootout' && result.status === 3)) {
203
+ throw new Error(`launchctl ${argv[0]} failed: ${result.stderr.trim()}`);
204
+ }
205
+ };
206
+ function renderPlist(job) {
207
+ const entry = (argument) => `<string>${escapeXml(argument)}</string>`;
208
+ return `<?xml version="1.0" encoding="UTF-8"?>
209
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
210
+ <plist version="1.0"><dict>
211
+ <key>Label</key><string>${label}</string>
212
+ <key>ProgramArguments</key><array>${job.programArguments.map(entry).join('')}</array>
213
+ <key>StartCalendarInterval</key><dict><key>Hour</key><integer>${schedule.hour}</integer><key>Minute</key><integer>${schedule.minute}</integer></dict>
214
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${escapeXml(job.path)}</string></dict>
215
+ <key>WorkingDirectory</key><string>${escapeXml(job.root)}</string>
216
+ <key>StandardOutPath</key><string>${escapeXml(join(job.root, 'auto-update.log'))}</string>
217
+ <key>StandardErrorPath</key><string>${escapeXml(join(job.root, 'auto-update-error.log'))}</string>
218
+ </dict></plist>
219
+ `;
220
+ }
221
+ const xmlEntities = {
222
+ '<': '&lt;',
223
+ '>': '&gt;',
224
+ '&': '&amp;',
225
+ '"': '&quot;',
226
+ "'": '&apos;',
227
+ };
228
+ const escapeXml = (value) => value.replace(/[<>&"']/g, (character) => xmlEntities[character]);
229
+ /** `candidate` が `installed` より新しい安定版か。安定版 (x.y.z) 以外は throw する。 */
230
+ export function newer(candidate, installed) {
231
+ const parse = (value) => {
232
+ if (typeof value !== 'string' || !/^\d+\.\d+\.\d+$/.test(value)) {
233
+ throw new Error(`Expected a stable version, got ${value}`);
234
+ }
235
+ return value.split('.').map(Number);
236
+ };
237
+ const candidateParts = parse(candidate);
238
+ const installedParts = parse(installed);
239
+ for (let index = 0; index < 3; index++) {
240
+ if (candidateParts[index] !== installedParts[index]) {
241
+ return candidateParts[index] > installedParts[index];
242
+ }
243
+ }
244
+ return false;
245
+ }
package/dist/lib/fs.js ADDED
@@ -0,0 +1,49 @@
1
+ import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { basename, dirname, join } from 'node:path';
4
+ /** OS エラーの code (ENOENT など)。Error 以外や code を持たないエラーは undefined。 */
5
+ export const errorCode = (error) => error instanceof Error ? error.code : undefined;
6
+ export const errorMessage = (error) => error instanceof Error ? error.message : String(error);
7
+ export const readJson = async (path) => JSON.parse(await readFile(path, 'utf8'));
8
+ /** JSON ファイルを読む。無ければ null。 */
9
+ export async function readJsonIfExists(path) {
10
+ try {
11
+ return await readJson(path);
12
+ }
13
+ catch (error) {
14
+ if (errorCode(error) === 'ENOENT')
15
+ return null;
16
+ throw error;
17
+ }
18
+ }
19
+ /**
20
+ * テキストファイルを読む。無ければ null。シンボリックリンクは辿らずに throw する。リンク先にある
21
+ * ユーザーのファイルを読み書きしないため。
22
+ */
23
+ export async function readTextIfExists(path) {
24
+ try {
25
+ if ((await lstat(path)).isSymbolicLink()) {
26
+ throw new Error(`Symlink preserved: ${path}. Replace it with a regular file or remove it, then retry.`);
27
+ }
28
+ return await readFile(path, 'utf8');
29
+ }
30
+ catch (error) {
31
+ if (errorCode(error) === 'ENOENT')
32
+ return null;
33
+ throw error;
34
+ }
35
+ }
36
+ /**
37
+ * 同じディレクトリの一時ファイルに書いてから rename する。読む側が書きかけの内容を見ることはなく、
38
+ * 既存のシンボリックリンクも辿らずに置き換える。
39
+ */
40
+ export async function writeFileAtomic(path, content) {
41
+ const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
42
+ try {
43
+ await writeFile(temporary, content, { flag: 'wx' });
44
+ await rename(temporary, path);
45
+ }
46
+ finally {
47
+ await rm(temporary, { force: true });
48
+ }
49
+ }
@@ -0,0 +1,204 @@
1
+ import { readFile, readdir, cp, rm, mkdir } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { digest, registerPlugin, replaceInstallation, restore, } from './transaction.js';
6
+ import { withLock } from './lock.js';
7
+ import { readTextIfExists, writeFileAtomic } from './fs.js';
8
+ import { codexHomeMismatch, codexHomeOf, isRecordedCodexHome, managedAgent, managedRoot, marketplaceName, packageRoot, payloadDir, payloadPaths, pluginDir, pluginId, statePath, } from './layout.js';
9
+ const actions = ['install', 'update', 'uninstall', 'recover'];
10
+ const isAction = (value) => actions.includes(value);
11
+ const usage = [
12
+ 'Usage: shinaps-skills <install|update|uninstall|recover> codex',
13
+ ' shinaps-skills auto-update <enable|disable|status|check> codex',
14
+ 'Claude Code: /plugin marketplace add sh1n4ps/shinaps-skills',
15
+ ' /plugin install shinaps@shinaps-skills',
16
+ ].join('\n');
17
+ const installGuidance = [
18
+ 'No Shinaps AI account is required to install. For MCP features, create an account at https://app.shinaps.ai and use your client’s authentication flow.',
19
+ 'MCP setup (only if not already configured): codex mcp add shinaps-ai --url https://app.shinaps.ai/mcp',
20
+ 'Then: codex mcp login shinaps-ai',
21
+ ];
22
+ /**
23
+ * `shinaps-skills <install|update|uninstall|recover> codex` を実行する。どの操作も、開始時に中断した
24
+ * 更新があれば restore で元に戻す。
25
+ * @param args コマンドライン引数 (node とスクリプトのパスを除いたもの)。例: `['install', 'codex']`。
26
+ * @param options 動作環境の指定。`auto-update check` とテストが使う。
27
+ */
28
+ export async function run(args, options = {}) {
29
+ const log = options.log ?? console.log;
30
+ const [action, target, ...extra] = args;
31
+ if (!action || action === '--help' || action === 'help') {
32
+ log(usage);
33
+ return;
34
+ }
35
+ if (!isAction(action) || target !== 'codex' || extra.length) {
36
+ throw new Error(`Unknown command: ${args.join(' ')}. ${usage.split('\n')[0]} (see --help).`);
37
+ }
38
+ const installation = openInstallation(options);
39
+ switch (action) {
40
+ case 'recover':
41
+ return recover(installation);
42
+ case 'uninstall':
43
+ return uninstall(installation, options);
44
+ case 'install':
45
+ case 'update':
46
+ return install(installation, action);
47
+ }
48
+ }
49
+ export function openInstallation(options) {
50
+ const home = options.home ?? homedir();
51
+ const codexHome = codexHomeOf(options, home);
52
+ return {
53
+ root: managedRoot(home),
54
+ codexHome,
55
+ agentsDir: join(codexHome, 'agents'),
56
+ command: options.command ?? codexCommand(codexHome),
57
+ source: options.source ?? packageRoot,
58
+ log: options.log ?? console.log,
59
+ };
60
+ }
61
+ /** 中断した更新があれば元に戻す。無ければ何もしない。 */
62
+ export function recover(installation) {
63
+ return withLock(installation.root, () => restore(installation));
64
+ }
65
+ /**
66
+ * 配布物とエージェントを `installation.source` の内容に置き換える。state.json の記録と違うエージェントが
67
+ * あれば何も変更せずに止める。
68
+ * @param action install と update の違いは完了メッセージだけ。
69
+ */
70
+ export async function install(installation, action) {
71
+ const { root, codexHome, agentsDir, command, log } = installation;
72
+ await withLock(root, async () => {
73
+ await restore(installation);
74
+ const recorded = await readVerifiedState(installation);
75
+ const state = recorded?.state ?? { agents: {}, codexHome };
76
+ const nextAgents = await readPackagedAgents(installation, state);
77
+ const preparedDir = await preparePayload(installation);
78
+ const journal = {
79
+ state: recorded?.text ?? null,
80
+ agents: {},
81
+ codexHome,
82
+ nextHashes: {},
83
+ };
84
+ for (const name of new Set([...Object.keys(state.agents), ...Object.keys(nextAgents)])) {
85
+ journal.agents[name] = await readTextIfExists(join(agentsDir, name));
86
+ journal.nextHashes[name] = nextAgents[name]?.hash ?? null;
87
+ }
88
+ const nextState = {
89
+ agents: Object.fromEntries(Object.entries(nextAgents).map(([name, { hash }]) => [name, hash])),
90
+ codexHome,
91
+ };
92
+ const removed = Object.keys(state.agents).filter((name) => !(name in nextAgents));
93
+ await replaceInstallation(installation, journal, preparedDir, async () => {
94
+ // codex コマンドより前に作る。codex は既定のディレクトリだけを自分で作り、指定された
95
+ // CODEX_HOME が存在しないと失敗するため。
96
+ await mkdir(agentsDir, { recursive: true });
97
+ await registerPlugin(command, payloadDir(root));
98
+ for (const [name, { content }] of Object.entries(nextAgents)) {
99
+ await writeFileAtomic(join(agentsDir, name), content);
100
+ }
101
+ for (const name of removed)
102
+ await rm(join(agentsDir, name), { force: true });
103
+ await writeFileAtomic(statePath(root), JSON.stringify(nextState, null, 2) + '\n');
104
+ });
105
+ });
106
+ if (action === 'update') {
107
+ log('Shinaps updated. Start a new Codex session to load the new skills and agents.');
108
+ return;
109
+ }
110
+ log('Shinaps installed. Start a new Codex session to load skills and agents.');
111
+ for (const line of installGuidance)
112
+ log(line);
113
+ }
114
+ async function uninstall(installation, options) {
115
+ const { root, agentsDir, command, log } = installation;
116
+ await withLock(root, async () => {
117
+ await restore(installation);
118
+ const recorded = await readVerifiedState(installation);
119
+ if (!recorded) {
120
+ log('No managed Shinaps installation found.');
121
+ return;
122
+ }
123
+ // auto-update.ts は check でこのモジュールを使う。相互 import を避けるため実行時に読み込む。
124
+ const { autoUpdate } = await import('./auto-update.js');
125
+ await autoUpdate(['disable', 'codex'], options);
126
+ await command(['plugin', 'remove', pluginId]);
127
+ await command(['plugin', 'marketplace', 'remove', marketplaceName]);
128
+ for (const name of Object.keys(recorded.state.agents)) {
129
+ await rm(join(agentsDir, name), { force: true });
130
+ }
131
+ await rm(payloadDir(root), { recursive: true, force: true });
132
+ await rm(statePath(root));
133
+ log('Removed managed Codex plugin and agents. Other skills and MCP credentials were preserved.');
134
+ });
135
+ }
136
+ // codex にはファイル操作と同じ CODEX_HOME を見せる。options.codexHome (`auto-update check` が渡す)
137
+ // はシェルの環境変数と違い得るので、解決済みの値を明示的に渡す。
138
+ const codexCommand = (codexHome) => (argv) => {
139
+ const result = spawnSync('codex', argv, {
140
+ stdio: 'inherit',
141
+ env: { ...process.env, CODEX_HOME: codexHome },
142
+ });
143
+ if (result.error)
144
+ throw result.error;
145
+ if (result.status !== 0) {
146
+ throw new Error(`codex ${argv.join(' ')} exited with ${result.status ?? result.signal}. Fix the error codex reported above, then retry.`);
147
+ }
148
+ };
149
+ /**
150
+ * state.json を読み、記録された導入先とエージェントを今回の実行で扱えることを確かめる。無ければ null。
151
+ * 名前の接頭辞は所有の証明にならないので、記録した内容と一致するファイルだけを置き換え・削除する。
152
+ * ユーザーが編集したファイルがあれば、何も変更しないうちに止めて、移動か復元を求める。
153
+ */
154
+ async function readVerifiedState(installation) {
155
+ const { root, codexHome, agentsDir } = installation;
156
+ const path = statePath(root);
157
+ const text = await readTextIfExists(path);
158
+ if (text === null)
159
+ return null;
160
+ const state = JSON.parse(text);
161
+ // state.json は記録した CODEX_HOME のエージェントだけを管理する。別の CODEX_HOME で実行した
162
+ // uninstall が、そちらにある同名のファイルを削除しないように、一致しなければ止める。
163
+ if (!isRecordedCodexHome(state.codexHome, codexHome)) {
164
+ throw new Error(`${codexHomeMismatch(state.codexHome, codexHome)} Run again with CODEX_HOME=${state.codexHome}, or run "CODEX_HOME=${state.codexHome} shinaps-skills uninstall codex" first.`);
165
+ }
166
+ for (const [name, hash] of Object.entries(state.agents)) {
167
+ if (!managedAgent.test(name))
168
+ throw new Error(`Invalid agent filename "${name}" in ${path}.`);
169
+ const current = await readTextIfExists(join(agentsDir, name));
170
+ if (current === null)
171
+ continue;
172
+ const currentHash = digest(current);
173
+ if (currentHash !== hash && currentHash !== state.pending?.[name]) {
174
+ throw new Error(`Modified agent preserved: ${join(agentsDir, name)}. Move the edited file elsewhere or restore its content, then retry.`);
175
+ }
176
+ }
177
+ return { text, state };
178
+ }
179
+ /** パッケージに入っているエージェント。導入先に Shinaps が置いていない同名ファイルがあれば止める。 */
180
+ async function readPackagedAgents(installation, state) {
181
+ const { source, agentsDir } = installation;
182
+ const directory = join(source, pluginDir, 'agents');
183
+ const agents = {};
184
+ for (const name of await readdir(directory)) {
185
+ if (!managedAgent.test(name))
186
+ continue;
187
+ const content = await readFile(join(directory, name), 'utf8');
188
+ if (!(name in state.agents) && (await readTextIfExists(join(agentsDir, name))) !== null) {
189
+ throw new Error(`Unmanaged agent preserved: ${join(agentsDir, name)}. Shinaps did not install this file. Move or rename it, then retry.`);
190
+ }
191
+ agents[name] = { content, hash: digest(content) };
192
+ }
193
+ return agents;
194
+ }
195
+ // npx のダウンロードキャッシュは一時的。配布物は管理ディレクトリにコピーし、後続の Codex セッションと
196
+ // 更新がキャッシュに依存しないようにする。
197
+ async function preparePayload(installation) {
198
+ const prepared = join(installation.root, 'prepared');
199
+ await rm(prepared, { recursive: true, force: true });
200
+ for (const path of payloadPaths) {
201
+ await cp(join(installation.source, path), join(prepared, path), { recursive: true });
202
+ }
203
+ return prepared;
204
+ }
@@ -0,0 +1,50 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join, dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ // ファイルの配置と形式。
5
+ // launchd ジョブは enable した版の CLI ではなく、実行のたびに npm から取得した最新版の CLI を起動する。
6
+ // そのためここに定義した場所と形式 (auto-update.json、state.json、transaction/journal.json) は
7
+ // どの版でも読めるように保ち、変える場合は旧形式も読めるようにする。
8
+ /**
9
+ * このパッケージのルートディレクトリ。リポジトリの checkout、または npm が展開したディレクトリ。
10
+ * このファイルは checkout では `lib/`、配布物では `dist/lib/` にあり深さが違うので、package.json が
11
+ * ある最も近い親ディレクトリを探す。
12
+ */
13
+ export const packageRoot = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
14
+ function findPackageRoot(directory) {
15
+ if (existsSync(join(directory, 'package.json')))
16
+ return directory;
17
+ const parent = dirname(directory);
18
+ if (parent === directory)
19
+ throw new Error(`package.json not found above ${directory}`);
20
+ return findPackageRoot(parent);
21
+ }
22
+ /** Codex plugin のディレクトリ。パッケージルートからも配置済み配布物からも同じ相対パス。 */
23
+ export const pluginDir = 'plugins/shinaps-codex';
24
+ export const manifestPath = `${pluginDir}/.codex-plugin/plugin.json`;
25
+ /** Codex の marketplace 定義。 */
26
+ const marketplacePath = '.agents/plugins/marketplace.json';
27
+ /** 配布物としてパッケージからコピーするパス。 */
28
+ export const payloadPaths = [pluginDir, marketplacePath];
29
+ /** marketplace.json の name と、その中の plugin の name。 */
30
+ export const marketplaceName = 'shinaps-skills-codex';
31
+ export const pluginId = `shinaps@${marketplaceName}`;
32
+ /**
33
+ * インストーラが扱えるエージェントのファイル名。一致しないファイルは置き換えも削除もしない。一致しても
34
+ * 所有の証明にはならず、実際に置き換え・削除するのは state.json に記録した内容と一致するファイルだけ。
35
+ */
36
+ export const managedAgent = /^shinaps-[a-z0-9-]+\.toml$/;
37
+ /** 管理ディレクトリ。配布物、state.json、自動更新の設定とロックを置く。 */
38
+ export const managedRoot = (home) => join(home, '.local', 'share', 'shinaps-ai', 'skills');
39
+ /** 配置済みの配布物。Codex の marketplace はここを指す。 */
40
+ export const payloadDir = (root) => join(root, 'codex');
41
+ export const statePath = (root) => join(root, 'state.json');
42
+ /** CODEX_HOME の解決値。オプション (`auto-update check` が渡す) → 環境変数 → Codex の既定の順。 */
43
+ export const codexHomeOf = (options, home) => resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(home, '.codex'));
44
+ /**
45
+ * 記録された CODEX_HOME が今回の導入先と同じか。旧版 (0.11.0) は指定された値を resolve せずに
46
+ * 保存していたので、記録側を resolve して比べる。
47
+ */
48
+ export const isRecordedCodexHome = (recorded, current) => resolve(recorded) === current;
49
+ /** CODEX_HOME 不一致のメッセージの共通部分。続けて対処を書く。 */
50
+ export const codexHomeMismatch = (recorded, current) => `Shinaps is installed in CODEX_HOME=${recorded}, but this run uses ${current}.`;
@@ -0,0 +1,109 @@
1
+ import { mkdir, readFile, writeFile, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { errorCode } from './fs.js';
4
+ /**
5
+ * `<directory>/update.lock` を保持したまま `work` を実行する。ロックはディレクトリで、保持者の pid を
6
+ * `pid` ファイルに記録する。保持者が存在しないロックは置き換える。保持者が生きているか pid をまだ読め
7
+ * ないロックは "Another Shinaps update is running" で、別の処理が古いロックの判定中なら
8
+ * "Another Shinaps update is recovering its lock" で reject する。
9
+ * 入れ子にできるのは別のディレクトリ同士だけ。scheduler ロックの中で installation ロックを取る
10
+ * (`auto-update check`) 場合と、その逆 (uninstall) の両方がある。保持中のロックは待たずに即失敗する
11
+ * ので、どちらの向きでもデッドロックしない。
12
+ * @param directory ロックの親ディレクトリ。無ければ作る。古いロックの置き換え中は `lock-recovery`
13
+ * もここに置く。
14
+ * @param work ロック保持中に実行する処理。戻り値をそのまま返す。
15
+ */
16
+ export async function withLock(directory, work) {
17
+ await mkdir(directory, { recursive: true });
18
+ const lock = join(directory, 'update.lock');
19
+ try {
20
+ await mkdir(lock);
21
+ }
22
+ catch (error) {
23
+ if (errorCode(error) !== 'EEXIST')
24
+ throw error;
25
+ await replaceStaleLock(directory, lock);
26
+ }
27
+ try {
28
+ await writeFile(join(lock, 'pid'), String(process.pid));
29
+ return await work();
30
+ }
31
+ finally {
32
+ await rm(lock, { recursive: true, force: true });
33
+ }
34
+ }
35
+ // 古いロックの置き換えは、lock-recovery ディレクトリを作れた 1 プロセスだけが行う。複数のプロセスが
36
+ // 同時に置き換えると、別のプロセスが取得した直後のロックを削除してしまうため。lock-recovery はこの
37
+ // 関数の中だけで保持し、work の前に削除する。この間に強制終了されると lock-recovery が残り、自動では
38
+ // 削除されないので、エラーメッセージにパスを含めて手で削除できるようにする。
39
+ async function replaceStaleLock(directory, lock) {
40
+ const recovery = join(directory, 'lock-recovery');
41
+ try {
42
+ await mkdir(recovery);
43
+ }
44
+ catch (error) {
45
+ if (errorCode(error) !== 'EEXIST')
46
+ throw error;
47
+ throw new Error(`Another Shinaps update is recovering its lock: ${recovery}. Retry later; remove that directory only after confirming no update is running.`);
48
+ }
49
+ try {
50
+ const pidPath = join(lock, 'pid');
51
+ const holder = await readLockPid(pidPath);
52
+ if (holderMayBeAlive(holder))
53
+ throw lockBusyError(lock);
54
+ // pid を読んでから生存を確認するまでの間に、保持者が終了してロックを解放し、別のプロセスが取得
55
+ // している可能性がある。そのため pid を読み直し、同じ終了済みの pid のままのときだけ古いロック
56
+ // として削除する。読み直しから削除までの間は、保持者が終了済みで、ほかの置き換え処理も
57
+ // lock-recovery で止まるため、ロックが変わることはない。
58
+ if ((await readLockPid(pidPath)) !== holder)
59
+ throw lockBusyError(lock);
60
+ await rm(lock, { recursive: true, force: true });
61
+ try {
62
+ await mkdir(lock);
63
+ }
64
+ catch (error) {
65
+ if (errorCode(error) !== 'EEXIST')
66
+ throw error;
67
+ // 上の rm と mkdir の間に別のプロセスが通常の経路で取得した。
68
+ throw lockBusyError(lock);
69
+ }
70
+ }
71
+ finally {
72
+ await rm(recovery, { recursive: true, force: true });
73
+ }
74
+ }
75
+ // pid ファイルの本文。保持者がまだ書いていなければ null。
76
+ async function readLockPid(pidPath) {
77
+ try {
78
+ return await readFile(pidPath, 'utf8');
79
+ }
80
+ catch (error) {
81
+ if (errorCode(error) !== 'ENOENT')
82
+ throw error;
83
+ return null;
84
+ }
85
+ }
86
+ // pid ファイルが無い、空または数値でない、別ユーザーのプロセス (EPERM)、のいずれも実行中として扱う。
87
+ // 保持者が mkdir と pid の書き込みの間にいる場合と、その間に強制終了された場合を区別できないため。
88
+ // ESRCH のときだけ保持者がいないとみなす。強制終了で残ったロックは手で削除する (エラーメッセージに
89
+ // パスを含める)。
90
+ function holderMayBeAlive(text) {
91
+ if (text === null)
92
+ return true;
93
+ const pid = Number(text);
94
+ if (!Number.isSafeInteger(pid) || pid <= 0)
95
+ return true;
96
+ try {
97
+ process.kill(pid, 0);
98
+ return true;
99
+ }
100
+ catch (error) {
101
+ const code = errorCode(error);
102
+ if (code === 'ESRCH')
103
+ return false;
104
+ if (code === 'EPERM')
105
+ return true;
106
+ throw error;
107
+ }
108
+ }
109
+ const lockBusyError = (lock) => new Error(`Another Shinaps update is running: ${lock}. Retry after it finishes; remove the lock only after confirming no update is running.`);
@@ -0,0 +1,115 @@
1
+ import { mkdir, rm, rename, cp } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { managedAgent, marketplaceName, payloadDir, pluginId, statePath } from './layout.js';
5
+ import { errorCode, errorMessage, readJsonIfExists, readTextIfExists, writeFileAtomic, } from './fs.js';
6
+ /** エージェント内容の sha256。state.json と journal の `nextHashes` はこの形式で記録する。 */
7
+ export const digest = (text) => createHash('sha256').update(text).digest('hex');
8
+ /** 配布物を Codex に登録する。install と restore で同じ手順を使う。 */
9
+ export async function registerPlugin(command, payload) {
10
+ await command(['plugin', 'marketplace', 'add', payload]);
11
+ await command(['plugin', 'add', pluginId]);
12
+ }
13
+ const journalDir = (root) => join(root, 'transaction');
14
+ /**
15
+ * 配置済みの配布物を `preparedDir` で置き換えて `apply` を実行する。両方を取り消せるだけの記録を
16
+ * `<root>/transaction` に残す。失敗したら `restore` を実行し、元のエラーを再 throw する。restore も
17
+ * 失敗した場合は両方のエラーを持つ AggregateError を throw し、バックアップは `<root>/transaction`
18
+ * に残る。次回の install / update / uninstall / recover が、開始時に restore をやり直す。
19
+ * @param target 更新の対象。
20
+ * @param journal 変更前の記録。`hadPayload` を付けて journal.json として保存する。
21
+ * @param preparedDir 準備済みの配布物。`<root>/codex` と同じファイルシステムにあること。rename で
22
+ * 配置するので成功後は存在しない。
23
+ * @param apply 配布物を置いたあとに行う処理。Codex への登録、エージェントの書き込み、state.json。
24
+ */
25
+ export async function replaceInstallation(target, journal, preparedDir, apply) {
26
+ const dir = journalDir(target.root);
27
+ const payload = payloadDir(target.root);
28
+ await rm(dir, { recursive: true, force: true });
29
+ await mkdir(dir);
30
+ let hadPayload = true;
31
+ try {
32
+ await cp(payload, join(dir, 'payload'), { recursive: true });
33
+ }
34
+ catch (error) {
35
+ if (errorCode(error) !== 'ENOENT')
36
+ throw error;
37
+ hadPayload = false;
38
+ }
39
+ // 一時ファイルに書いてから rename するので、journal.json が書きかけの内容で読まれることはない。
40
+ // fsync はしていないため、電源断のあとは journal が残らず、配布物やエージェントが更新途中のまま残る
41
+ // ことがある。
42
+ const saved = { ...journal, hadPayload };
43
+ await writeFileAtomic(join(dir, 'journal.json'), JSON.stringify(saved));
44
+ try {
45
+ await rm(payload, { recursive: true, force: true });
46
+ await rename(preparedDir, payload);
47
+ await apply();
48
+ }
49
+ catch (error) {
50
+ try {
51
+ await restore(target);
52
+ }
53
+ catch (recovery) {
54
+ throw new AggregateError([error, recovery], `${errorMessage(error)}; recovery incomplete: ${errorMessage(recovery)}. Backup retained in ${dir}; run "shinaps-skills recover codex" after fixing the cause.`, { cause: error });
55
+ }
56
+ throw error;
57
+ }
58
+ await removeJournal(dir);
59
+ }
60
+ /**
61
+ * `replaceInstallation` が書いた `<root>/transaction/journal.json` の内容で、中断した install / update
62
+ * の前の状態に戻す。journal が無ければ何もせず返る。journal が別の CODEX_HOME のものか、中断後に
63
+ * エージェントファイルが編集されている (旧内容でも新内容でもない) 場合は、何も変更せずに throw する。
64
+ * journal が消えるまで何度でも繰り返せる。journal の削除が最後の手順で、完了の印になる。
65
+ * @param target 更新の対象。`command` は、Codex が plugin を独自のキャッシュにも保持しているため、
66
+ * 戻した配布物を登録し直すのに使う。
67
+ */
68
+ export async function restore(target) {
69
+ const { root, codexHome, command } = target;
70
+ const dir = journalDir(root);
71
+ const journalPath = join(dir, 'journal.json');
72
+ const journal = await readJsonIfExists(journalPath);
73
+ if (!journal)
74
+ return;
75
+ if (journal.codexHome !== codexHome) {
76
+ throw new Error(`An interrupted update in CODEX_HOME=${journal.codexHome} must be recovered first, but this run uses ${codexHome}. Run "CODEX_HOME=${journal.codexHome} shinaps-skills recover codex".`);
77
+ }
78
+ const agentsDir = join(codexHome, 'agents');
79
+ for (const [name, priorContent] of Object.entries(journal.agents)) {
80
+ if (!managedAgent.test(name)) {
81
+ throw new Error(`Invalid agent filename "${name}" in ${journalPath}.`);
82
+ }
83
+ const current = await readTextIfExists(join(agentsDir, name));
84
+ if (current === null)
85
+ continue;
86
+ if (current !== priorContent && digest(current) !== journal.nextHashes[name]) {
87
+ throw new Error(`Modified agent preserved during recovery: ${join(agentsDir, name)}. Move the edited file elsewhere, then run "shinaps-skills recover codex".`);
88
+ }
89
+ }
90
+ const payload = payloadDir(root);
91
+ await rm(payload, { recursive: true, force: true });
92
+ if (journal.hadPayload)
93
+ await cp(join(dir, 'payload'), payload, { recursive: true });
94
+ await mkdir(agentsDir, { recursive: true });
95
+ for (const [name, content] of Object.entries(journal.agents)) {
96
+ if (content === null)
97
+ await rm(join(agentsDir, name), { force: true });
98
+ else
99
+ await writeFileAtomic(join(agentsDir, name), content);
100
+ }
101
+ if (journal.state === null)
102
+ await rm(statePath(root), { force: true });
103
+ else
104
+ await writeFileAtomic(statePath(root), journal.state);
105
+ // Codex は plugin を独自のキャッシュにも保持しているため、ファイルを戻したあとで登録し直す。
106
+ if (journal.hadPayload)
107
+ await registerPlugin(command, payload);
108
+ await removeJournal(dir);
109
+ }
110
+ // journal の削除が完了の時点。順序が重要で、payload/ が先に消えて journal が残ると、次の restore が
111
+ // 新しい配布物を削除したうえで古い配布物のコピーに失敗する。
112
+ async function removeJournal(dir) {
113
+ await rm(join(dir, 'journal.json'));
114
+ await rm(dir, { recursive: true });
115
+ }
package/package.json CHANGED
@@ -1,13 +1,41 @@
1
1
  {
2
2
  "name": "@shinaps/skills",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Install Shinaps AI workflows and custom agents for Codex",
5
5
  "type": "module",
6
- "bin": { "shinaps-skills": "bin/skills.mjs" },
7
- "files": ["bin/", "lib/", "codex/", "plugins/", ".agents/plugins/marketplace.json", "README.md", "LICENSE"],
8
- "engines": { "node": ">=22" },
9
- "scripts": { "test": "node --test", "check": "node --check bin/skills.mjs && node --check lib/install.mjs" },
10
- "repository": { "type": "git", "url": "https://github.com/sh1n4ps/shinaps-skills.git" },
6
+ "bin": {
7
+ "shinaps-skills": "dist/bin/skills.js"
8
+ },
9
+ "files": [
10
+ "dist/",
11
+ "plugins/",
12
+ ".agents/plugins/marketplace.json",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.build.json",
21
+ "prepack": "npm run build",
22
+ "test": "node --test \"test/*.test.ts\"",
23
+ "check": "npm run format:check && tsc --noEmit",
24
+ "verify:package": "node scripts/verify-package.ts",
25
+ "format": "biome format --write .",
26
+ "format:check": "biome format ."
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/sh1n4ps/shinaps-skills.git"
31
+ },
11
32
  "license": "MIT",
12
- "publishConfig": { "access": "public" }
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "devDependencies": {
37
+ "@biomejs/biome": "2.5.15",
38
+ "@types/node": "^26.6.4",
39
+ "typescript": "^7.0.2"
40
+ }
13
41
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shinaps",
3
3
  "description": "Shinaps AIの開発ワークフロー。プロジェクトの指示から条件を読み、調査・設計・実装・簡素化・UI検証・レビュー・コミット・PR・リリースを支援する12スキル。",
4
- "version": "0.11.0",
4
+ "version": "0.12.0",
5
5
  "author": {
6
6
  "name": "shinaps",
7
7
  "email": "ryuta@shinaps.jp"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shinaps",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Shinaps AIのCodex向け開発ワークフロー",
5
5
  "skills": "./skills/"
6
6
  }
package/bin/skills.mjs DELETED
@@ -1,9 +0,0 @@
1
- #!/usr/bin/env node
2
- import { run } from '../lib/install.mjs';
3
-
4
- try {
5
- await run(process.argv.slice(2));
6
- } catch (error) {
7
- console.error(`Shinaps: ${error.message}`);
8
- process.exitCode = 1;
9
- }
package/lib/install.mjs DELETED
@@ -1,116 +0,0 @@
1
- import { mkdir, readFile, writeFile, readdir, cp, rm, rename, lstat } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
- import { join, dirname, resolve } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { createHash, randomUUID } from 'node:crypto';
6
- import { spawnSync } from 'node:child_process';
7
-
8
- const source = resolve(dirname(fileURLToPath(import.meta.url)), '..');
9
- const plugin = 'shinaps@shinaps-skills-codex';
10
- const digest = text => createHash('sha256').update(text).digest('hex');
11
-
12
- async function optional(path) {
13
- try {
14
- if ((await lstat(path)).isSymbolicLink()) throw new Error(`Symlink preserved: ${path}`);
15
- return await readFile(path, 'utf8');
16
- }
17
- catch (error) { if (error.code === 'ENOENT') return null; throw error; }
18
- }
19
-
20
- export async function run(args, options = {}) {
21
- const [action, target, ...extra] = args;
22
- const log = options.log ?? console.log;
23
- if (!action || action === '--help' || action === 'help') {
24
- log('Usage: shinaps-skills <install|update|uninstall> codex\nClaude Code: /plugin marketplace add sh1n4ps/shinaps-skills\n /plugin install shinaps@shinaps-skills');
25
- return;
26
- }
27
- if (!['install', 'update', 'uninstall'].includes(action) || target !== 'codex' || extra.length) {
28
- throw new Error('Use install, update or uninstall followed by codex. See --help.');
29
- }
30
- const home = options.home ?? homedir();
31
- const codexHome = options.codexHome ?? process.env.CODEX_HOME ?? join(home, '.codex');
32
- const root = join(home, '.local', 'share', 'shinaps-ai', 'skills');
33
- const statePath = join(root, 'state.json');
34
- const payload = join(root, 'codex');
35
- const agentsDir = join(codexHome, 'agents');
36
- const stateText = await optional(statePath);
37
- const state = stateText ? JSON.parse(stateText) : { agents: {}, codexHome };
38
- // Ownership is bound to the installation target, so a changed CODEX_HOME cannot
39
- // make an uninstall remove a different account's files.
40
- if (state.codexHome !== codexHome) throw new Error('CODEX_HOME differs from the existing install. Use the original target first.');
41
- const command = options.command ?? ((argv) => {
42
- const result = spawnSync('codex', argv, { stdio: 'inherit' });
43
- if (result.error) throw result.error;
44
- if (result.status !== 0) throw new Error(`codex ${argv.join(' ')} failed. Fix the reported error and retry; installed state is retained.`);
45
- });
46
- const save = async () => {
47
- await mkdir(root, { recursive: true });
48
- await writeFile(statePath + '.tmp', JSON.stringify(state, null, 2) + '\n');
49
- await rename(statePath + '.tmp', statePath);
50
- };
51
- // A prefix does not prove ownership. Only exact recorded content is replaced
52
- // or deleted; a user's edits require their explicit resolution.
53
- for (const [name, hash] of Object.entries(state.agents)) {
54
- if (!/^shinaps-[a-z0-9-]+\.toml$/.test(name)) throw new Error('Invalid managed agent filename.');
55
- const current = await optional(join(agentsDir, name));
56
- if (current !== null && digest(current) !== hash && digest(current) !== state.pending?.[name]) throw new Error(`Modified agent preserved: ${join(agentsDir, name)}`);
57
- }
58
- if (action === 'uninstall') {
59
- if (!stateText) { log('No managed Shinaps installation found.'); return; }
60
- await command(['plugin', 'remove', plugin]);
61
- await command(['plugin', 'marketplace', 'remove', 'shinaps-skills-codex']);
62
- for (const name of Object.keys(state.agents)) await rm(join(agentsDir, name), { force: true });
63
- await rm(payload, { recursive: true, force: true });
64
- await rm(statePath);
65
- log('Removed managed Codex plugin and agents. Other skills and MCP credentials were preserved.');
66
- return;
67
- }
68
- const agentSource = join(options.source ?? source, 'codex', 'agents');
69
- const next = {};
70
- for (const name of await readdir(agentSource)) {
71
- if (!/^shinaps-[a-z0-9-]+\.toml$/.test(name)) continue;
72
- const content = await readFile(join(agentSource, name), 'utf8');
73
- const current = await optional(join(agentsDir, name));
74
- if (!(name in state.agents) && current !== null) throw new Error(`Unmanaged agent preserved: ${join(agentsDir, name)}. Remove or relocate the old Shinaps registration first.`);
75
- next[name] = { content, hash: digest(content) };
76
- }
77
- // npx's download cache is temporary. Keep the marketplace payload at a stable
78
- // location so later Codex sessions and updates do not depend on that cache.
79
- await rm(payload, { recursive: true, force: true });
80
- await mkdir(payload, { recursive: true });
81
- await cp(join(options.source ?? source, 'plugins', 'shinaps-codex'), join(payload, 'plugins', 'shinaps-codex'), { recursive: true });
82
- await mkdir(join(payload, '.agents', 'plugins'), { recursive: true });
83
- await cp(join(options.source ?? source, '.agents', 'plugins', 'marketplace.json'), join(payload, '.agents', 'plugins', 'marketplace.json'));
84
- await save();
85
- await command(['plugin', 'marketplace', 'add', payload]);
86
- await command(['plugin', 'add', plugin]);
87
- await mkdir(agentsDir, { recursive: true });
88
- for (const [name, { content, hash }] of Object.entries(next)) {
89
- // Keep both the old and new digest until the atomic replacement completes.
90
- // Retrying after an interrupted update can then recognize either version.
91
- state.agents[name] ??= hash;
92
- state.pending = { [name]: hash };
93
- await save();
94
- const temporary = join(agentsDir, `.${name}.${randomUUID()}.tmp`);
95
- try {
96
- await writeFile(temporary, content, { flag: 'wx' });
97
- await rename(temporary, join(agentsDir, name));
98
- } finally {
99
- await rm(temporary, { force: true });
100
- }
101
- state.agents[name] = hash;
102
- delete state.pending;
103
- await save();
104
- }
105
- for (const name of Object.keys(state.agents)) {
106
- if (!(name in next)) {
107
- await rm(join(agentsDir, name), { force: true });
108
- delete state.agents[name];
109
- }
110
- }
111
- await save();
112
- log('Shinaps installed. Start a new Codex session to load skills and agents.');
113
- log('No Shinaps AI account is required to install. For MCP features, create an account at https://app.shinaps.ai and use your client’s authentication flow.');
114
- log('MCP setup (only if not already configured): codex mcp add shinaps-ai --url https://app.shinaps.ai/mcp');
115
- log('Then: codex mcp login shinaps-ai');
116
- }