cclaw-cli 0.5.15 → 0.5.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -120,6 +120,12 @@ Required repository secret:
120
120
  └── runs/ # archived feature snapshots (YYYY-MM-DD-feature-name)
121
121
  ```
122
122
 
123
+ ## Harness Integration
124
+
125
+ Supported harnesses: `claude`, `cursor`, `opencode`, `codex`. The full
126
+ per-harness install surface, feature matrix, and lifecycle details live in
127
+ [docs/harnesses.md](./docs/harnesses.md).
128
+
123
129
  ## License
124
130
 
125
131
  [MIT](./LICENSE)
package/dist/cli.d.ts CHANGED
@@ -6,7 +6,10 @@ interface ParsedArgs {
6
6
  harnesses?: HarnessId[];
7
7
  reconcileGates?: boolean;
8
8
  archiveName?: string;
9
+ showHelp?: boolean;
10
+ showVersion?: boolean;
9
11
  }
12
+ export declare function usage(): string;
10
13
  declare function parseHarnesses(raw: string): HarnessId[];
11
14
  declare function parseArgs(argv: string[]): ParsedArgs;
12
15
  export { parseArgs, parseHarnesses };
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from "node:fs";
2
+ import { readFileSync, realpathSync } from "node:fs";
3
3
  import process from "node:process";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -9,18 +9,63 @@ import { initCclaw, syncCclaw, uninstallCclaw, upgradeCclaw } from "./install.js
9
9
  import { error, info } from "./logger.js";
10
10
  import { archiveRun } from "./runs.js";
11
11
  const INSTALLER_COMMANDS = ["init", "sync", "doctor", "upgrade", "uninstall", "archive"];
12
- function usage() {
12
+ export function usage() {
13
13
  return `cclaw - installer-first flow toolkit
14
14
 
15
15
  Usage:
16
- cclaw init [--harnesses=claude,cursor,opencode,codex]
17
- cclaw sync
18
- cclaw doctor [--reconcile-gates]
19
- cclaw archive [--name=feature-name]
20
- cclaw upgrade
21
- cclaw uninstall
16
+ cclaw <command> [flags]
17
+ cclaw --help | -h
18
+ cclaw --version | -v
19
+
20
+ Commands:
21
+ init Bootstrap .cclaw runtime, state, and harness shims in this project.
22
+ Flags: --harnesses=<list> Comma list of harnesses (claude,cursor,opencode,codex).
23
+ sync Regenerate harness shim files from the current .cclaw config (non-destructive).
24
+ doctor Run health checks against the local .cclaw runtime. Exit code 2 on failure.
25
+ Flags: --reconcile-gates Recompute current-stage gate evidence before checks.
26
+ archive Move .cclaw/artifacts into .cclaw/runs/<date>-<slug> and reset flow state.
27
+ Flags: --name=<feature> Feature slug (default: inferred from 00-idea.md).
28
+ upgrade Refresh generated files in .cclaw without modifying user artifacts.
29
+ uninstall Remove .cclaw runtime and the generated harness shim files.
30
+
31
+ Global flags:
32
+ -h, --help Show this help message and exit 0.
33
+ -v, --version Print the cclaw CLI version and exit 0.
34
+
35
+ Examples:
36
+ cclaw init --harnesses=claude,cursor
37
+ cclaw doctor --reconcile-gates
38
+ cclaw archive --name=payments-revamp
39
+
40
+ Docs: https://github.com/zuevrs/cclaw
41
+ Issues: https://github.com/zuevrs/cclaw/issues
22
42
  `;
23
43
  }
44
+ function cliPackageVersion() {
45
+ try {
46
+ const here = path.dirname(fileURLToPath(import.meta.url));
47
+ const candidates = [
48
+ path.resolve(here, "../package.json"),
49
+ path.resolve(here, "../../package.json")
50
+ ];
51
+ for (const candidate of candidates) {
52
+ try {
53
+ const raw = readFileSync(candidate, "utf8");
54
+ const parsed = JSON.parse(raw);
55
+ if (parsed.name === "cclaw-cli" && typeof parsed.version === "string") {
56
+ return parsed.version;
57
+ }
58
+ }
59
+ catch {
60
+ continue;
61
+ }
62
+ }
63
+ }
64
+ catch {
65
+ // fall through
66
+ }
67
+ return "unknown";
68
+ }
24
69
  function parseHarnesses(raw) {
25
70
  const requested = raw
26
71
  .split(",")
@@ -33,11 +78,19 @@ function parseHarnesses(raw) {
33
78
  return requested;
34
79
  }
35
80
  function parseArgs(argv) {
36
- const [commandRaw, ...flags] = argv;
37
- const command = INSTALLER_COMMANDS.includes(commandRaw)
81
+ const parsed = {};
82
+ const helpFlag = argv.find((arg) => arg === "--help" || arg === "-h");
83
+ if (helpFlag) {
84
+ parsed.showHelp = true;
85
+ }
86
+ const versionFlag = argv.find((arg) => arg === "--version" || arg === "-v");
87
+ if (versionFlag) {
88
+ parsed.showVersion = true;
89
+ }
90
+ const [commandRaw, ...flags] = argv.filter((arg) => arg !== "--help" && arg !== "-h" && arg !== "--version" && arg !== "-v");
91
+ parsed.command = INSTALLER_COMMANDS.includes(commandRaw)
38
92
  ? commandRaw
39
93
  : undefined;
40
- const parsed = { command };
41
94
  for (const flag of flags) {
42
95
  if (flag.startsWith("--harnesses=")) {
43
96
  parsed.harnesses = parseHarnesses(flag.replace("--harnesses=", ""));
@@ -54,6 +107,14 @@ function parseArgs(argv) {
54
107
  return parsed;
55
108
  }
56
109
  async function runCommand(parsed, ctx) {
110
+ if (parsed.showHelp) {
111
+ ctx.stdout.write(usage());
112
+ return 0;
113
+ }
114
+ if (parsed.showVersion) {
115
+ ctx.stdout.write(`cclaw ${cliPackageVersion()}\n`);
116
+ return 0;
117
+ }
57
118
  const command = parsed.command;
58
119
  if (!command) {
59
120
  ctx.stderr.write(usage());
@@ -88,7 +149,10 @@ async function runCommand(parsed, ctx) {
88
149
  }
89
150
  if (command === "archive") {
90
151
  const archived = await archiveRun(ctx.cwd, parsed.archiveName);
91
- info(ctx, `Archived active artifacts to ${archived.archivePath}. Flow state reset to brainstorm.`);
152
+ const snapshotSummary = archived.snapshottedStateFiles.length > 0
153
+ ? ` Snapshotted ${archived.snapshottedStateFiles.length} state file(s) under ${archived.archivePath}/state and wrote archive-manifest.json.`
154
+ : "";
155
+ info(ctx, `Archived active artifacts to ${archived.archivePath}. Flow state reset to brainstorm.${snapshotSummary}`);
92
156
  return 0;
93
157
  }
94
158
  await uninstallCclaw(ctx.cwd);
@@ -216,7 +216,7 @@ export function agentRoutingTable() {
216
216
  | Brainstorm (start with \`/cc <idea>\`) | planner | — |
217
217
  | Scope / Design / Spec / Plan (advance via \`/cc-next\`) | planner | security-reviewer on design, spec-reviewer on spec |
218
218
  | TDD (via \`/cc-next\`) | test-author | doc-updater |
219
- | Review (via \`/cc-next\`) | spec-reviewer, code-reviewer | security-reviewer |
219
+ | Review (via \`/cc-next\`) | spec-reviewer, code-reviewer, security-reviewer | — |
220
220
  | Ship (via \`/cc-next\`) | — | doc-updater |
221
221
  `;
222
222
  }
@@ -231,8 +231,8 @@ Cclaw provides specialist agents under \`.cclaw/agents/\` for targeted delegatio
231
231
  ${agentRoutingTable()}
232
232
 
233
233
  **Activation modes:**
234
- - **Mandatory:** MUST be used when the related stage runs (spec-reviewer, code-reviewer during review)
235
- - **Proactive:** Should be used automatically when context matches (planner for complex features, security-reviewer for auth code)
234
+ - **Mandatory:** MUST be used when the related stage runs (spec-reviewer, code-reviewer, and security-reviewer during review; planner during scope and design; test-author during tdd; doc-updater during ship). Even if a change has no trust-boundary impact, security-reviewer produces an explicit no-change attestation.
235
+ - **Proactive:** Should be used automatically when context matches (planner for complex features, security-reviewer escalations outside review, doc-updater on behavior changes)
236
236
  - **On-demand:** Invoked only when explicitly requested
237
237
 
238
238
  **Agent files:** \`.cclaw/agents/{name}.md\` — each contains YAML frontmatter with tools and model tier.
@@ -1556,9 +1556,9 @@ const STAGE_AUTO_SUBAGENT_DISPATCH = {
1556
1556
  },
1557
1557
  {
1558
1558
  agent: "security-reviewer",
1559
- mode: "proactive",
1560
- when: "When auth, input validation, secrets, parser, or privileged actions changed.",
1561
- purpose: "Raise exploitable findings before release.",
1559
+ mode: "mandatory",
1560
+ when: "Always in review stage. Even when no trust boundaries changed, produce an explicit 'no-change' security attestation.",
1561
+ purpose: "Guarantee a dedicated security pass on every diff: auth, input validation, secrets, injection, privilege, and blast-radius review are never opt-in.",
1562
1562
  requiresUserGate: false
1563
1563
  }
1564
1564
  ],
@@ -35,7 +35,7 @@ For cclaw flow stages, machine-only specialist work should auto-dispatch without
35
35
 
36
36
  - **design/plan:** planner
37
37
  - **tdd:** test-author
38
- - **review:** spec-reviewer + code-reviewer (security-reviewer when trust boundaries moved)
38
+ - **review:** spec-reviewer + code-reviewer + security-reviewer (security-reviewer is always mandatory; produce an explicit no-change attestation when no trust boundaries moved)
39
39
  - **ship:** doc-updater
40
40
 
41
41
  Human input remains mandatory only at explicit approval gates (plan approval, user challenge resolution, release finalization mode).
@@ -137,9 +137,9 @@ export const ARTIFACT_TEMPLATES = {
137
137
 
138
138
  ## Architecture Diagram
139
139
 
140
- \\\`\\\`\\\`
140
+ \`\`\`
141
141
  (ASCII, Mermaid, or tool-generated diagram showing component boundaries and data flow direction)
142
- \\\`\\\`\\\`
142
+ \`\`\`
143
143
 
144
144
  ## What Already Exists
145
145
  | Sub-problem | Existing code/library | Layer | Reuse decision |
package/dist/runs.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type FlowState } from "./flow-state.js";
2
+ import type { FlowStage } from "./types.js";
2
3
  export interface CclawRunMeta {
3
4
  id: string;
4
5
  title: string;
@@ -10,10 +11,26 @@ export interface ArchiveRunResult {
10
11
  archivedAt: string;
11
12
  featureName: string;
12
13
  resetState: FlowState;
14
+ snapshottedStateFiles: string[];
15
+ }
16
+ export interface ArchiveManifest {
17
+ version: 1;
18
+ archiveId: string;
19
+ archivedAt: string;
20
+ featureName: string;
21
+ sourceRunId: string;
22
+ sourceCurrentStage: FlowStage;
23
+ sourceCompletedStages: FlowStage[];
24
+ snapshottedStateFiles: string[];
13
25
  }
14
26
  interface EnsureRunSystemOptions {
15
27
  createIfMissing?: boolean;
16
28
  }
29
+ export declare class CorruptFlowStateError extends Error {
30
+ readonly statePath: string;
31
+ readonly quarantinedPath: string;
32
+ constructor(statePath: string, quarantinedPath: string, cause: unknown);
33
+ }
17
34
  export declare function readFlowState(projectRoot: string): Promise<FlowState>;
18
35
  export declare function writeFlowState(projectRoot: string, state: FlowState): Promise<void>;
19
36
  export declare function ensureRunSystem(projectRoot: string, _options?: EnsureRunSystemOptions): Promise<FlowState>;
package/dist/runs.js CHANGED
@@ -6,7 +6,13 @@ import { ensureDir, exists, withDirectoryLock, writeFileSafe } from "./fs-utils.
6
6
  const FLOW_STATE_REL_PATH = `${RUNTIME_ROOT}/state/flow-state.json`;
7
7
  const RUNS_DIR_REL_PATH = `${RUNTIME_ROOT}/runs`;
8
8
  const ACTIVE_ARTIFACTS_REL_PATH = `${RUNTIME_ROOT}/artifacts`;
9
+ const STATE_DIR_REL_PATH = `${RUNTIME_ROOT}/state`;
9
10
  const FLOW_STAGE_SET = new Set(COMMAND_FILE_ORDER);
11
+ /** State filenames explicitly excluded from the archive snapshot. */
12
+ const STATE_SNAPSHOT_EXCLUDE = new Set([
13
+ ".flow-state.lock",
14
+ ".delegation.lock"
15
+ ]);
10
16
  function flowStatePath(projectRoot) {
11
17
  return path.join(projectRoot, FLOW_STATE_REL_PATH);
12
18
  }
@@ -19,6 +25,46 @@ function runsRoot(projectRoot) {
19
25
  function activeArtifactsPath(projectRoot) {
20
26
  return path.join(projectRoot, ACTIVE_ARTIFACTS_REL_PATH);
21
27
  }
28
+ function stateDirPath(projectRoot) {
29
+ return path.join(projectRoot, STATE_DIR_REL_PATH);
30
+ }
31
+ async function snapshotStateDirectory(projectRoot, destinationRoot) {
32
+ const sourceDir = stateDirPath(projectRoot);
33
+ if (!(await exists(sourceDir))) {
34
+ return [];
35
+ }
36
+ await ensureDir(destinationRoot);
37
+ const copied = [];
38
+ let entries;
39
+ try {
40
+ entries = await fs.readdir(sourceDir, { withFileTypes: true });
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ for (const entry of entries) {
46
+ if (STATE_SNAPSHOT_EXCLUDE.has(entry.name))
47
+ continue;
48
+ if (entry.name.startsWith(".") && !entry.name.endsWith(".json"))
49
+ continue;
50
+ const from = path.join(sourceDir, entry.name);
51
+ const to = path.join(destinationRoot, entry.name);
52
+ try {
53
+ if (entry.isDirectory()) {
54
+ await fs.cp(from, to, { recursive: true });
55
+ copied.push(`${entry.name}/`);
56
+ }
57
+ else if (entry.isFile()) {
58
+ await fs.copyFile(from, to);
59
+ copied.push(entry.name);
60
+ }
61
+ }
62
+ catch {
63
+ // best-effort snapshot; continue on individual failures
64
+ }
65
+ }
66
+ return copied.sort((a, b) => a.localeCompare(b));
67
+ }
22
68
  function isFlowStage(value) {
23
69
  return typeof value === "string" && FLOW_STAGE_SET.has(value);
24
70
  }
@@ -144,18 +190,66 @@ async function uniqueArchiveId(projectRoot, baseId) {
144
190
  }
145
191
  return candidate;
146
192
  }
193
+ export class CorruptFlowStateError extends Error {
194
+ statePath;
195
+ quarantinedPath;
196
+ constructor(statePath, quarantinedPath, cause) {
197
+ super(`Corrupt flow-state.json detected at ${statePath}. ` +
198
+ `Quarantined to ${quarantinedPath}. ` +
199
+ `Inspect the quarantined file, reconcile by hand, then re-run your command ` +
200
+ `or delete ${statePath} to start over. ` +
201
+ `Underlying error: ${cause instanceof Error ? cause.message : String(cause)}`);
202
+ this.name = "CorruptFlowStateError";
203
+ this.statePath = statePath;
204
+ this.quarantinedPath = quarantinedPath;
205
+ if (cause instanceof Error) {
206
+ this.cause = cause;
207
+ }
208
+ }
209
+ }
210
+ function quarantineTimestamp(date = new Date()) {
211
+ return date.toISOString().replace(/[:.]/gu, "-");
212
+ }
213
+ async function quarantineCorruptState(statePath, cause) {
214
+ const quarantinedPath = `${statePath}.corrupt-${quarantineTimestamp()}.json`;
215
+ try {
216
+ await fs.rename(statePath, quarantinedPath);
217
+ }
218
+ catch (renameErr) {
219
+ try {
220
+ const raw = await fs.readFile(statePath, "utf8");
221
+ await fs.writeFile(quarantinedPath, raw, "utf8");
222
+ await fs.unlink(statePath).catch(() => undefined);
223
+ }
224
+ catch {
225
+ throw new CorruptFlowStateError(statePath, quarantinedPath, renameErr);
226
+ }
227
+ }
228
+ throw new CorruptFlowStateError(statePath, quarantinedPath, cause);
229
+ }
147
230
  export async function readFlowState(projectRoot) {
148
231
  const statePath = flowStatePath(projectRoot);
149
232
  if (!(await exists(statePath))) {
150
233
  return createInitialFlowState();
151
234
  }
235
+ let raw;
152
236
  try {
153
- const parsed = JSON.parse(await fs.readFile(statePath, "utf8"));
154
- return coerceFlowState(parsed);
237
+ raw = await fs.readFile(statePath, "utf8");
155
238
  }
156
- catch {
157
- return createInitialFlowState();
239
+ catch (readErr) {
240
+ throw new CorruptFlowStateError(statePath, statePath, readErr);
241
+ }
242
+ let parsed;
243
+ try {
244
+ parsed = JSON.parse(raw);
245
+ }
246
+ catch (parseErr) {
247
+ await quarantineCorruptState(statePath, parseErr);
248
+ }
249
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
250
+ await quarantineCorruptState(statePath, new Error("flow-state.json did not deserialize to a JSON object"));
158
251
  }
252
+ return coerceFlowState(parsed);
159
253
  }
160
254
  export async function writeFlowState(projectRoot, state) {
161
255
  await withDirectoryLock(flowStateLockPath(projectRoot), async () => {
@@ -214,17 +308,32 @@ export async function archiveRun(projectRoot, featureName) {
214
308
  const archiveId = await uniqueArchiveId(projectRoot, archiveBaseId);
215
309
  const archivePath = path.join(runsDir, archiveId);
216
310
  const archiveArtifactsPath = path.join(archivePath, "artifacts");
311
+ const sourceState = await readFlowState(projectRoot);
217
312
  await ensureDir(archivePath);
218
313
  await fs.rename(artifactsDir, archiveArtifactsPath);
219
314
  await ensureDir(artifactsDir);
315
+ const archiveStatePath = path.join(archivePath, "state");
316
+ const snapshottedStateFiles = await snapshotStateDirectory(projectRoot, archiveStatePath);
220
317
  const resetState = createInitialFlowState();
221
318
  await writeFlowState(projectRoot, resetState);
222
319
  const archivedAt = new Date().toISOString();
320
+ const manifest = {
321
+ version: 1,
322
+ archiveId,
323
+ archivedAt,
324
+ featureName: feature,
325
+ sourceRunId: sourceState.activeRunId,
326
+ sourceCurrentStage: sourceState.currentStage,
327
+ sourceCompletedStages: sourceState.completedStages,
328
+ snapshottedStateFiles
329
+ };
330
+ await writeFileSafe(path.join(archivePath, "archive-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
223
331
  return {
224
332
  archiveId,
225
333
  archivePath,
226
334
  archivedAt,
227
335
  featureName: feature,
228
- resetState
336
+ resetState,
337
+ snapshottedStateFiles
229
338
  };
230
339
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.5.15",
3
+ "version": "0.5.16",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,7 @@
20
20
  "build": "npm run clean:dist && tsc -p tsconfig.json",
21
21
  "test": "vitest run",
22
22
  "test:watch": "vitest",
23
+ "test:coverage": "vitest run --coverage",
23
24
  "smoke:runtime": "npm run build && node scripts/smoke-init.mjs",
24
25
  "lint:hooks": "npm run build && node scripts/lint-generated-hooks.mjs",
25
26
  "build:plugin-manifests": "npm run build && node scripts/build-plugin-manifests.mjs",
@@ -42,6 +43,7 @@
42
43
  },
43
44
  "devDependencies": {
44
45
  "@types/node": "^24.7.2",
46
+ "@vitest/coverage-v8": "^3.2.4",
45
47
  "typescript": "^5.9.3",
46
48
  "vitest": "^3.2.4"
47
49
  }