helloloop 0.3.1 → 0.7.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 (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +4 -4
  3. package/README.md +194 -81
  4. package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +1 -1
  5. package/hosts/claude/marketplace/plugins/helloloop/commands/helloloop.md +17 -13
  6. package/hosts/claude/marketplace/plugins/helloloop/skills/helloloop/SKILL.md +12 -5
  7. package/hosts/gemini/extension/GEMINI.md +14 -7
  8. package/hosts/gemini/extension/commands/helloloop.toml +17 -12
  9. package/hosts/gemini/extension/gemini-extension.json +1 -1
  10. package/package.json +2 -2
  11. package/skills/helloloop/SKILL.md +18 -7
  12. package/src/analyze_confirmation.mjs +29 -5
  13. package/src/analyze_prompt.mjs +5 -1
  14. package/src/analyze_user_input.mjs +20 -2
  15. package/src/analyzer.mjs +130 -43
  16. package/src/cli.mjs +32 -492
  17. package/src/cli_analyze_command.mjs +248 -0
  18. package/src/cli_args.mjs +106 -0
  19. package/src/cli_command_handlers.mjs +120 -0
  20. package/src/cli_context.mjs +31 -0
  21. package/src/cli_render.mjs +70 -0
  22. package/src/cli_support.mjs +11 -14
  23. package/src/completion_review.mjs +243 -0
  24. package/src/config.mjs +51 -0
  25. package/src/discovery.mjs +21 -2
  26. package/src/discovery_prompt.mjs +2 -27
  27. package/src/email_notification.mjs +343 -0
  28. package/src/engine_metadata.mjs +79 -0
  29. package/src/engine_process_support.mjs +294 -0
  30. package/src/engine_selection.mjs +335 -0
  31. package/src/engine_selection_failure.mjs +51 -0
  32. package/src/engine_selection_messages.mjs +119 -0
  33. package/src/engine_selection_probe.mjs +78 -0
  34. package/src/engine_selection_prompt.mjs +48 -0
  35. package/src/engine_selection_settings.mjs +104 -0
  36. package/src/global_config.mjs +21 -0
  37. package/src/guardrails.mjs +15 -4
  38. package/src/install.mjs +6 -405
  39. package/src/install_claude.mjs +189 -0
  40. package/src/install_codex.mjs +114 -0
  41. package/src/install_gemini.mjs +43 -0
  42. package/src/install_shared.mjs +138 -0
  43. package/src/process.mjs +567 -100
  44. package/src/prompt.mjs +9 -5
  45. package/src/prompt_session.mjs +40 -0
  46. package/src/runner.mjs +3 -341
  47. package/src/runner_execute_task.mjs +255 -0
  48. package/src/runner_execution_support.mjs +146 -0
  49. package/src/runner_loop.mjs +106 -0
  50. package/src/runner_once.mjs +29 -0
  51. package/src/runner_status.mjs +104 -0
  52. package/src/runtime_recovery.mjs +302 -0
  53. package/src/shell_invocation.mjs +16 -0
  54. package/templates/analysis-output.schema.json +0 -1
  55. package/templates/policy.template.json +25 -0
  56. package/templates/project.template.json +2 -0
  57. package/templates/task-review-output.schema.json +70 -0
@@ -0,0 +1,189 @@
1
+ import path from "node:path";
2
+
3
+ import { ensureDir, fileExists, nowIso, readJson, writeJson } from "./common.mjs";
4
+ import {
5
+ CLAUDE_MARKETPLACE_NAME,
6
+ CLAUDE_PLUGIN_KEY,
7
+ assertPathInside,
8
+ copyDirectory,
9
+ removePathIfExists,
10
+ removeTargetIfNeeded,
11
+ resolveHomeDir,
12
+ } from "./install_shared.mjs";
13
+
14
+ function updateClaudeSettings(settingsFile, marketplaceRoot) {
15
+ const settings = fileExists(settingsFile) ? readJson(settingsFile) : {};
16
+
17
+ settings.extraKnownMarketplaces = settings.extraKnownMarketplaces || {};
18
+ settings.enabledPlugins = settings.enabledPlugins || {};
19
+ settings.extraKnownMarketplaces[CLAUDE_MARKETPLACE_NAME] = {
20
+ source: {
21
+ source: "directory",
22
+ path: marketplaceRoot,
23
+ },
24
+ };
25
+ settings.enabledPlugins[CLAUDE_PLUGIN_KEY] = true;
26
+
27
+ writeJson(settingsFile, settings);
28
+ }
29
+
30
+ function removeClaudeSettingsEntries(settingsFile) {
31
+ if (!fileExists(settingsFile)) {
32
+ return false;
33
+ }
34
+
35
+ const settings = readJson(settingsFile);
36
+ let changed = false;
37
+
38
+ if (settings.extraKnownMarketplaces && Object.hasOwn(settings.extraKnownMarketplaces, CLAUDE_MARKETPLACE_NAME)) {
39
+ delete settings.extraKnownMarketplaces[CLAUDE_MARKETPLACE_NAME];
40
+ changed = true;
41
+ }
42
+ if (settings.enabledPlugins && Object.hasOwn(settings.enabledPlugins, CLAUDE_PLUGIN_KEY)) {
43
+ delete settings.enabledPlugins[CLAUDE_PLUGIN_KEY];
44
+ changed = true;
45
+ }
46
+
47
+ if (changed) {
48
+ writeJson(settingsFile, settings);
49
+ }
50
+ return changed;
51
+ }
52
+
53
+ function updateClaudeKnownMarketplaces(knownMarketplacesFile, marketplaceRoot, updatedAt) {
54
+ const knownMarketplaces = fileExists(knownMarketplacesFile) ? readJson(knownMarketplacesFile) : {};
55
+ knownMarketplaces[CLAUDE_MARKETPLACE_NAME] = {
56
+ source: {
57
+ source: "directory",
58
+ path: marketplaceRoot,
59
+ },
60
+ installLocation: marketplaceRoot,
61
+ lastUpdated: updatedAt,
62
+ };
63
+ writeJson(knownMarketplacesFile, knownMarketplaces);
64
+ }
65
+
66
+ function removeClaudeKnownMarketplace(knownMarketplacesFile) {
67
+ if (!fileExists(knownMarketplacesFile)) {
68
+ return false;
69
+ }
70
+
71
+ const knownMarketplaces = readJson(knownMarketplacesFile);
72
+ if (!Object.hasOwn(knownMarketplaces, CLAUDE_MARKETPLACE_NAME)) {
73
+ return false;
74
+ }
75
+
76
+ delete knownMarketplaces[CLAUDE_MARKETPLACE_NAME];
77
+ writeJson(knownMarketplacesFile, knownMarketplaces);
78
+ return true;
79
+ }
80
+
81
+ function updateClaudeInstalledPlugins(installedPluginsFile, pluginRoot, pluginVersion, updatedAt) {
82
+ const installedPlugins = fileExists(installedPluginsFile)
83
+ ? readJson(installedPluginsFile)
84
+ : { version: 2, plugins: {} };
85
+
86
+ installedPlugins.version = 2;
87
+ installedPlugins.plugins = installedPlugins.plugins || {};
88
+ installedPlugins.plugins[CLAUDE_PLUGIN_KEY] = [
89
+ {
90
+ scope: "user",
91
+ installPath: pluginRoot,
92
+ version: pluginVersion,
93
+ installedAt: updatedAt,
94
+ lastUpdated: updatedAt,
95
+ },
96
+ ];
97
+
98
+ writeJson(installedPluginsFile, installedPlugins);
99
+ }
100
+
101
+ function removeClaudeInstalledPlugin(installedPluginsFile) {
102
+ if (!fileExists(installedPluginsFile)) {
103
+ return false;
104
+ }
105
+
106
+ const installedPlugins = readJson(installedPluginsFile);
107
+ if (!installedPlugins.plugins || !Object.hasOwn(installedPlugins.plugins, CLAUDE_PLUGIN_KEY)) {
108
+ return false;
109
+ }
110
+
111
+ delete installedPlugins.plugins[CLAUDE_PLUGIN_KEY];
112
+ writeJson(installedPluginsFile, installedPlugins);
113
+ return true;
114
+ }
115
+
116
+ export function installClaudeHost(bundleRoot, options = {}) {
117
+ const resolvedClaudeHome = resolveHomeDir(options.claudeHome, ".claude");
118
+ const sourceMarketplaceRoot = path.join(bundleRoot, "hosts", "claude", "marketplace");
119
+ const sourceManifest = path.join(bundleRoot, ".claude-plugin", "plugin.json");
120
+ const pluginVersion = readJson(sourceManifest).version || readJson(path.join(bundleRoot, "package.json")).version;
121
+ const targetPluginsRoot = path.join(resolvedClaudeHome, "plugins");
122
+ const targetMarketplaceRoot = path.join(targetPluginsRoot, "marketplaces", CLAUDE_MARKETPLACE_NAME);
123
+ const targetCachePluginsRoot = path.join(targetPluginsRoot, "cache", CLAUDE_MARKETPLACE_NAME, "helloloop");
124
+ const targetInstalledPluginRoot = path.join(targetCachePluginsRoot, pluginVersion);
125
+ const knownMarketplacesFile = path.join(targetPluginsRoot, "known_marketplaces.json");
126
+ const installedPluginsFile = path.join(targetPluginsRoot, "installed_plugins.json");
127
+ const settingsFile = path.join(resolvedClaudeHome, "settings.json");
128
+
129
+ if (!fileExists(sourceManifest)) {
130
+ throw new Error(`未找到 Claude 插件 manifest:${sourceManifest}`);
131
+ }
132
+ if (!fileExists(path.join(sourceMarketplaceRoot, ".claude-plugin", "marketplace.json"))) {
133
+ throw new Error(`未找到 Claude marketplace 模板:${sourceMarketplaceRoot}`);
134
+ }
135
+
136
+ assertPathInside(resolvedClaudeHome, targetMarketplaceRoot, "Claude marketplace 目录");
137
+ assertPathInside(resolvedClaudeHome, targetInstalledPluginRoot, "Claude 插件缓存目录");
138
+ removeTargetIfNeeded(targetMarketplaceRoot, options.force);
139
+ removeTargetIfNeeded(targetCachePluginsRoot, options.force);
140
+
141
+ ensureDir(targetPluginsRoot);
142
+ ensureDir(path.dirname(targetMarketplaceRoot));
143
+ ensureDir(path.dirname(targetInstalledPluginRoot));
144
+ copyDirectory(sourceMarketplaceRoot, targetMarketplaceRoot);
145
+ copyDirectory(path.join(sourceMarketplaceRoot, "plugins", "helloloop"), targetInstalledPluginRoot);
146
+ const updatedAt = nowIso();
147
+ updateClaudeSettings(settingsFile, targetMarketplaceRoot);
148
+ updateClaudeKnownMarketplaces(knownMarketplacesFile, targetMarketplaceRoot, updatedAt);
149
+ updateClaudeInstalledPlugins(installedPluginsFile, targetInstalledPluginRoot, pluginVersion, updatedAt);
150
+
151
+ return {
152
+ host: "claude",
153
+ displayName: "Claude",
154
+ targetRoot: targetInstalledPluginRoot,
155
+ marketplaceFile: path.join(targetMarketplaceRoot, ".claude-plugin", "marketplace.json"),
156
+ settingsFile,
157
+ };
158
+ }
159
+
160
+ export function uninstallClaudeHost(options = {}) {
161
+ const resolvedClaudeHome = resolveHomeDir(options.claudeHome, ".claude");
162
+ const targetPluginsRoot = path.join(resolvedClaudeHome, "plugins");
163
+ const targetMarketplaceRoot = path.join(targetPluginsRoot, "marketplaces", CLAUDE_MARKETPLACE_NAME);
164
+ const targetCachePluginsRoot = path.join(targetPluginsRoot, "cache", CLAUDE_MARKETPLACE_NAME);
165
+ const knownMarketplacesFile = path.join(targetPluginsRoot, "known_marketplaces.json");
166
+ const installedPluginsFile = path.join(targetPluginsRoot, "installed_plugins.json");
167
+ const settingsFile = path.join(resolvedClaudeHome, "settings.json");
168
+
169
+ const removedMarketplaceDir = removePathIfExists(targetMarketplaceRoot);
170
+ const removedCacheDir = removePathIfExists(targetCachePluginsRoot);
171
+ const removedKnownMarketplace = removeClaudeKnownMarketplace(knownMarketplacesFile);
172
+ const removedInstalledPlugin = removeClaudeInstalledPlugin(installedPluginsFile);
173
+ const removedSettingsEntries = removeClaudeSettingsEntries(settingsFile);
174
+
175
+ return {
176
+ host: "claude",
177
+ displayName: "Claude",
178
+ targetRoot: targetCachePluginsRoot,
179
+ removed: [
180
+ removedMarketplaceDir,
181
+ removedCacheDir,
182
+ removedKnownMarketplace,
183
+ removedInstalledPlugin,
184
+ removedSettingsEntries,
185
+ ].some(Boolean),
186
+ marketplaceFile: path.join(targetMarketplaceRoot, ".claude-plugin", "marketplace.json"),
187
+ settingsFile,
188
+ };
189
+ }
@@ -0,0 +1,114 @@
1
+ import path from "node:path";
2
+
3
+ import { ensureDir, fileExists, readJson, writeJson } from "./common.mjs";
4
+ import {
5
+ assertPathInside,
6
+ codexBundleEntries,
7
+ copyBundleEntries,
8
+ removePathIfExists,
9
+ removeTargetIfNeeded,
10
+ resolveHomeDir,
11
+ } from "./install_shared.mjs";
12
+
13
+ function updateCodexMarketplace(marketplaceFile) {
14
+ const marketplace = fileExists(marketplaceFile)
15
+ ? readJson(marketplaceFile)
16
+ : {
17
+ name: "local-plugins",
18
+ interface: {
19
+ displayName: "Local Plugins",
20
+ },
21
+ plugins: [],
22
+ };
23
+
24
+ marketplace.interface = marketplace.interface || {
25
+ displayName: "Local Plugins",
26
+ };
27
+ marketplace.plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : [];
28
+
29
+ const nextEntry = {
30
+ name: "helloloop",
31
+ source: {
32
+ source: "local",
33
+ path: "./plugins/helloloop",
34
+ },
35
+ policy: {
36
+ installation: "AVAILABLE",
37
+ authentication: "ON_INSTALL",
38
+ },
39
+ category: "Coding",
40
+ };
41
+
42
+ const existingIndex = marketplace.plugins.findIndex((plugin) => plugin?.name === "helloloop");
43
+ if (existingIndex >= 0) {
44
+ marketplace.plugins.splice(existingIndex, 1, nextEntry);
45
+ } else {
46
+ marketplace.plugins.push(nextEntry);
47
+ }
48
+
49
+ writeJson(marketplaceFile, marketplace);
50
+ }
51
+
52
+ function removeCodexMarketplaceEntry(marketplaceFile) {
53
+ if (!fileExists(marketplaceFile)) {
54
+ return false;
55
+ }
56
+
57
+ const marketplace = readJson(marketplaceFile);
58
+ const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : [];
59
+ const nextPlugins = plugins.filter((plugin) => plugin?.name !== "helloloop");
60
+ if (nextPlugins.length === plugins.length) {
61
+ return false;
62
+ }
63
+
64
+ marketplace.plugins = nextPlugins;
65
+ writeJson(marketplaceFile, marketplace);
66
+ return true;
67
+ }
68
+
69
+ export function installCodexHost(bundleRoot, options = {}) {
70
+ const resolvedCodexHome = resolveHomeDir(options.codexHome, ".codex");
71
+ const targetPluginsRoot = path.join(resolvedCodexHome, "plugins");
72
+ const targetPluginRoot = path.join(targetPluginsRoot, "helloloop");
73
+ const marketplaceFile = path.join(resolvedCodexHome, ".agents", "plugins", "marketplace.json");
74
+ const manifestFile = path.join(bundleRoot, ".codex-plugin", "plugin.json");
75
+
76
+ if (!fileExists(manifestFile)) {
77
+ throw new Error(`未找到 Codex 插件 manifest:${manifestFile}`);
78
+ }
79
+
80
+ assertPathInside(resolvedCodexHome, targetPluginRoot, "Codex 目标插件目录");
81
+ removeTargetIfNeeded(targetPluginRoot, options.force);
82
+
83
+ ensureDir(targetPluginsRoot);
84
+ ensureDir(targetPluginRoot);
85
+ copyBundleEntries(bundleRoot, targetPluginRoot, codexBundleEntries);
86
+ removePathIfExists(path.join(targetPluginRoot, ".git"));
87
+
88
+ ensureDir(path.dirname(marketplaceFile));
89
+ updateCodexMarketplace(marketplaceFile);
90
+
91
+ return {
92
+ host: "codex",
93
+ displayName: "Codex",
94
+ targetRoot: targetPluginRoot,
95
+ marketplaceFile,
96
+ };
97
+ }
98
+
99
+ export function uninstallCodexHost(options = {}) {
100
+ const resolvedCodexHome = resolveHomeDir(options.codexHome, ".codex");
101
+ const targetPluginRoot = path.join(resolvedCodexHome, "plugins", "helloloop");
102
+ const marketplaceFile = path.join(resolvedCodexHome, ".agents", "plugins", "marketplace.json");
103
+
104
+ const removedPlugin = removePathIfExists(targetPluginRoot);
105
+ const removedMarketplace = removeCodexMarketplaceEntry(marketplaceFile);
106
+
107
+ return {
108
+ host: "codex",
109
+ displayName: "Codex",
110
+ targetRoot: targetPluginRoot,
111
+ removed: removedPlugin || removedMarketplace,
112
+ marketplaceFile,
113
+ };
114
+ }
@@ -0,0 +1,43 @@
1
+ import path from "node:path";
2
+
3
+ import { ensureDir, fileExists } from "./common.mjs";
4
+ import {
5
+ assertPathInside,
6
+ copyDirectory,
7
+ removePathIfExists,
8
+ removeTargetIfNeeded,
9
+ resolveHomeDir,
10
+ } from "./install_shared.mjs";
11
+
12
+ export function installGeminiHost(bundleRoot, options = {}) {
13
+ const resolvedGeminiHome = resolveHomeDir(options.geminiHome, ".gemini");
14
+ const sourceExtensionRoot = path.join(bundleRoot, "hosts", "gemini", "extension");
15
+ const targetExtensionRoot = path.join(resolvedGeminiHome, "extensions", "helloloop");
16
+
17
+ if (!fileExists(path.join(sourceExtensionRoot, "gemini-extension.json"))) {
18
+ throw new Error(`未找到 Gemini 扩展清单:${sourceExtensionRoot}`);
19
+ }
20
+
21
+ assertPathInside(resolvedGeminiHome, targetExtensionRoot, "Gemini 扩展目录");
22
+ removeTargetIfNeeded(targetExtensionRoot, options.force);
23
+ ensureDir(path.dirname(targetExtensionRoot));
24
+ copyDirectory(sourceExtensionRoot, targetExtensionRoot);
25
+
26
+ return {
27
+ host: "gemini",
28
+ displayName: "Gemini",
29
+ targetRoot: targetExtensionRoot,
30
+ };
31
+ }
32
+
33
+ export function uninstallGeminiHost(options = {}) {
34
+ const resolvedGeminiHome = resolveHomeDir(options.geminiHome, ".gemini");
35
+ const targetExtensionRoot = path.join(resolvedGeminiHome, "extensions", "helloloop");
36
+
37
+ return {
38
+ host: "gemini",
39
+ displayName: "Gemini",
40
+ targetRoot: targetExtensionRoot,
41
+ removed: removePathIfExists(targetExtensionRoot),
42
+ };
43
+ }
@@ -0,0 +1,138 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { ensureDir, fileExists, readJson, writeJson } from "./common.mjs";
6
+
7
+ export const runtimeBundleEntries = [
8
+ ".claude-plugin",
9
+ ".codex-plugin",
10
+ "LICENSE",
11
+ "README.md",
12
+ "bin",
13
+ "hosts",
14
+ "package.json",
15
+ "scripts",
16
+ "skills",
17
+ "src",
18
+ "templates",
19
+ ];
20
+
21
+ export const codexBundleEntries = runtimeBundleEntries.filter((entry) => ![
22
+ ".claude-plugin",
23
+ "hosts",
24
+ ].includes(entry));
25
+
26
+ export const supportedHosts = ["codex", "claude", "gemini"];
27
+ export const CLAUDE_MARKETPLACE_NAME = "helloloop-local";
28
+ export const CLAUDE_PLUGIN_KEY = "helloloop@helloloop-local";
29
+
30
+ export function resolveHomeDir(homeDir, defaultDirName) {
31
+ return path.resolve(homeDir || path.join(os.homedir(), defaultDirName));
32
+ }
33
+
34
+ export function assertPathInside(parentDir, targetDir, label) {
35
+ const relative = path.relative(parentDir, targetDir);
36
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
37
+ throw new Error(`${label} 超出允许范围:${targetDir}`);
38
+ }
39
+ }
40
+
41
+ function sleepSync(ms) {
42
+ const shared = new SharedArrayBuffer(4);
43
+ const view = new Int32Array(shared);
44
+ Atomics.wait(view, 0, 0, Math.max(0, ms));
45
+ }
46
+
47
+ function isRetryableRemoveError(error) {
48
+ const code = String(error?.code || "").toUpperCase();
49
+ return ["ENOTEMPTY", "EPERM", "EBUSY"].includes(code);
50
+ }
51
+
52
+ function removeDirectoryWithRetries(targetPath) {
53
+ const retryDelaysMs = [0, 50, 150, 300];
54
+ let lastError = null;
55
+
56
+ for (const delayMs of retryDelaysMs) {
57
+ if (delayMs > 0) {
58
+ sleepSync(delayMs);
59
+ }
60
+ try {
61
+ fs.rmSync(targetPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
62
+ return;
63
+ } catch (error) {
64
+ lastError = error;
65
+ if (!isRetryableRemoveError(error)) {
66
+ throw error;
67
+ }
68
+ }
69
+ }
70
+
71
+ const tempPath = `${targetPath}.removing-${Date.now()}`;
72
+ fs.renameSync(targetPath, tempPath);
73
+ try {
74
+ fs.rmSync(tempPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
75
+ } catch (error) {
76
+ throw lastError || error;
77
+ }
78
+ }
79
+
80
+ function removeFsPath(targetPath) {
81
+ const stats = fs.lstatSync(targetPath);
82
+ if (stats.isDirectory() && !stats.isSymbolicLink()) {
83
+ removeDirectoryWithRetries(targetPath);
84
+ return;
85
+ }
86
+ fs.rmSync(targetPath, { force: true, recursive: true, maxRetries: 3, retryDelay: 100 });
87
+ }
88
+
89
+ export function removeTargetIfNeeded(targetPath, force) {
90
+ if (!fileExists(targetPath)) {
91
+ return;
92
+ }
93
+ if (!force) {
94
+ throw new Error(`目标目录已存在:${targetPath}。若要覆盖,请追加 --force。`);
95
+ }
96
+ removeFsPath(targetPath);
97
+ }
98
+
99
+ export function removePathIfExists(targetPath) {
100
+ if (!fileExists(targetPath)) {
101
+ return false;
102
+ }
103
+ removeFsPath(targetPath);
104
+ return true;
105
+ }
106
+
107
+ export function copyBundleEntries(bundleRoot, targetRoot, entries) {
108
+ for (const entry of entries) {
109
+ const sourcePath = path.join(bundleRoot, entry);
110
+ if (!fileExists(sourcePath)) {
111
+ continue;
112
+ }
113
+
114
+ fs.cpSync(sourcePath, path.join(targetRoot, entry), {
115
+ force: true,
116
+ recursive: true,
117
+ });
118
+ }
119
+ }
120
+
121
+ export function copyDirectory(sourceRoot, targetRoot) {
122
+ fs.cpSync(sourceRoot, targetRoot, {
123
+ force: true,
124
+ recursive: true,
125
+ });
126
+ }
127
+
128
+ export function loadOrInitJson(filePath, fallbackValue) {
129
+ if (!fileExists(filePath)) {
130
+ return fallbackValue;
131
+ }
132
+ return readJson(filePath);
133
+ }
134
+
135
+ export function writeJsonFile(filePath, value) {
136
+ ensureDir(path.dirname(filePath));
137
+ writeJson(filePath, value);
138
+ }