@moglenny/content-workbench-agent 0.1.1 → 0.1.3

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/bin.mjs +115 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moglenny/content-workbench-agent",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Agent installer for Content Workbench CLI, Skills, and MCP configuration",
5
5
  "type": "module",
6
6
  "bin": {
package/src/bin.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { spawn } from "node:child_process";
4
4
  import { realpathSync } from "node:fs";
5
- import { mkdir, stat } from "node:fs/promises";
5
+ import { mkdir, rename, rm, stat } from "node:fs/promises";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
@@ -10,6 +10,16 @@ import { fileURLToPath } from "node:url";
10
10
  const scriptPath = fileURLToPath(import.meta.url);
11
11
  const defaultRepoUrl = "https://github.com/Himog0921/topic-dashboard.git";
12
12
  const defaultBranch = "main";
13
+ const cloneRetryAttempts = 3;
14
+ const sparseCheckoutPaths = [
15
+ ".cw-agent",
16
+ ".claude/skills",
17
+ "bin",
18
+ "packages/cli",
19
+ "packages/mcp-server",
20
+ "scripts",
21
+ "public/releases/cw",
22
+ ];
13
23
 
14
24
  function expandHome(filePath) {
15
25
  if (filePath === "~") return os.homedir();
@@ -30,9 +40,12 @@ Options:
30
40
  --repo-url <url> Git repository URL. Defaults to the official content workbench repo.
31
41
  --branch <name> Branch to install from. Defaults to main.
32
42
  --repo-dir <path> Local managed clone path. Defaults to ~/.content-workbench/agent/topic-dashboard.
43
+ --cli-prefix <path> User-owned npm prefix for cw. Defaults to ~/.content-workbench/npm.
33
44
  --skills-target <path> Skill install target. Repeatable.
34
45
  --mcp-target <path> Write MCP config snippet to this JSON file.
35
- --skip-cli Do not install the global cw CLI.
46
+ --shell-profile <path> Shell profile that should include cw in PATH.
47
+ --skip-cli Do not install the user-level cw CLI.
48
+ --skip-shell-profile Do not add cw to the shell profile PATH.
36
49
  --skip-skills Do not copy Skills.
37
50
  --skip-mcp Do not write MCP config.
38
51
  --api-base-url <url> Base URL shown in post-install setup guidance.
@@ -82,13 +95,13 @@ export function parseAgentInstallerArgs(argv = process.argv.slice(2)) {
82
95
  options.repoDir = path.resolve(expandHome(args[++index] || ""));
83
96
  continue;
84
97
  }
85
- if (arg === "--skills-target" || arg === "--mcp-target" || arg === "--api-base-url") {
98
+ if (arg === "--skills-target" || arg === "--mcp-target" || arg === "--api-base-url" || arg === "--cli-prefix" || arg === "--shell-profile") {
86
99
  const value = args[++index];
87
100
  if (!value) throw new Error(`Missing value for ${arg}`);
88
101
  options.installArgs.push(arg, value);
89
102
  continue;
90
103
  }
91
- if (arg === "--skip-cli" || arg === "--skip-skills" || arg === "--skip-mcp") {
104
+ if (arg === "--skip-cli" || arg === "--skip-shell-profile" || arg === "--skip-skills" || arg === "--skip-mcp") {
92
105
  options.installArgs.push(arg);
93
106
  continue;
94
107
  }
@@ -108,7 +121,22 @@ export function buildInstallPlan(options, repoExists) {
108
121
  : [
109
122
  {
110
123
  command: "git",
111
- args: ["clone", "--depth", "1", "--branch", options.branch, options.repoUrl, options.repoDir],
124
+ args: [
125
+ "clone",
126
+ "--depth",
127
+ "1",
128
+ "--filter=blob:none",
129
+ "--sparse",
130
+ "--single-branch",
131
+ "--branch",
132
+ options.branch,
133
+ options.repoUrl,
134
+ options.repoDir,
135
+ ],
136
+ },
137
+ {
138
+ command: "git",
139
+ args: ["-C", options.repoDir, "sparse-checkout", "set", "--cone", ...sparseCheckoutPaths],
112
140
  },
113
141
  ];
114
142
 
@@ -157,6 +185,59 @@ async function run(command, args) {
157
185
  });
158
186
  }
159
187
 
188
+ function gitVerb(step) {
189
+ if (step.command !== "git") return null;
190
+ if (step.args[0] === "-C") return step.args[2] || null;
191
+ return step.args[0] || null;
192
+ }
193
+
194
+ function shouldRetryStep(step) {
195
+ return ["clone", "fetch", "pull"].includes(gitVerb(step));
196
+ }
197
+
198
+ function isCloneStep(step) {
199
+ return step.command === "git" && step.args[0] === "clone";
200
+ }
201
+
202
+ function sleep(ms) {
203
+ return new Promise((resolve) => setTimeout(resolve, ms));
204
+ }
205
+
206
+ async function runStepWithRetry(step, options) {
207
+ const maxAttempts = shouldRetryStep(step) ? cloneRetryAttempts : 1;
208
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
209
+ try {
210
+ await options.runner(step.command, step.args);
211
+ return;
212
+ } catch (error) {
213
+ if (attempt >= maxAttempts) throw error;
214
+ if (isCloneStep(step)) {
215
+ await options.removeDir(options.repoDir, { recursive: true, force: true });
216
+ }
217
+ options.stdout.write(`Retrying interrupted ${step.command} ${gitVerb(step)} (${attempt + 1}/${maxAttempts})...\n`);
218
+ await options.sleep(attempt * 1000);
219
+ }
220
+ }
221
+ }
222
+
223
+ async function runSteps(steps, options) {
224
+ for (const step of steps) {
225
+ await runStepWithRetry(step, options);
226
+ }
227
+ }
228
+
229
+ async function clearManagedRepo(options) {
230
+ try {
231
+ await options.removeDir(options.repoDir, { recursive: true, force: true });
232
+ return null;
233
+ } catch {
234
+ const quarantineDir = `${options.repoDir}.broken-${options.now()}`;
235
+ await options.renameDir(options.repoDir, quarantineDir);
236
+ options.stdout.write(`Moved inaccessible managed repository to ${quarantineDir}\n`);
237
+ return quarantineDir;
238
+ }
239
+ }
240
+
160
241
  function printPlan(plan, stdout) {
161
242
  stdout.write(`${plan.dryRun ? "Content Workbench Agent install dry run" : "Content Workbench Agent install"}\n`);
162
243
  stdout.write(`Managed repository: ${plan.repoDir}\n`);
@@ -183,8 +264,35 @@ export async function runAgentInstaller(argv = process.argv.slice(2), io = proce
183
264
  if (options.dryRun) return plan;
184
265
 
185
266
  const runner = io.run || run;
186
- for (const step of plan.steps) {
187
- await runner(step.command, step.args);
267
+ const removeDir = io.removeDir || rm;
268
+ const renameDir = io.renameDir || rename;
269
+ const wait = io.sleep || sleep;
270
+ const runnerOptions = {
271
+ runner,
272
+ removeDir,
273
+ renameDir,
274
+ repoDir: options.repoDir,
275
+ sleep: wait,
276
+ now: io.now || Date.now,
277
+ stdout: io.stdout,
278
+ };
279
+
280
+ if (repoExists) {
281
+ const updateSteps = plan.steps.slice(0, 3);
282
+ const installStep = plan.steps.at(-1);
283
+ try {
284
+ await runSteps(updateSteps, runnerOptions);
285
+ } catch {
286
+ io.stdout.write("Existing managed repository could not be updated. Recreating a clean managed repository...\n");
287
+ const quarantinedRepoDir = await clearManagedRepo(runnerOptions);
288
+ const cleanPlan = buildInstallPlan(options, false);
289
+ await runSteps(cleanPlan.steps, runnerOptions);
290
+ io.stdout.write("Content Workbench Agent install complete\n");
291
+ return { ...cleanPlan, recreatedExistingRepo: true, quarantinedRepoDir };
292
+ }
293
+ await runSteps([installStep], runnerOptions);
294
+ } else {
295
+ await runSteps(plan.steps, runnerOptions);
188
296
  }
189
297
 
190
298
  io.stdout.write("Content Workbench Agent install complete\n");