@intentius/chant 0.1.14 → 0.1.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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/src/build.ts +18 -2
  3. package/src/cli/commands/build.ts +9 -1
  4. package/src/cli/commands/import-live.test.ts +126 -0
  5. package/src/cli/commands/import.ts +152 -2
  6. package/src/cli/commands/migrate.ts +2 -2
  7. package/src/cli/handlers/{state.test.ts → lifecycle.test.ts} +80 -37
  8. package/src/cli/handlers/{state.ts → lifecycle.ts} +166 -40
  9. package/src/cli/handlers/misc.ts +31 -2
  10. package/src/cli/handlers/run.test.ts +98 -0
  11. package/src/cli/handlers/run.ts +123 -0
  12. package/src/cli/main.test.ts +14 -0
  13. package/src/cli/main.ts +49 -15
  14. package/src/cli/mcp/{state-tools.ts → lifecycle-tools.ts} +9 -9
  15. package/src/cli/mcp/op-tools.ts +2 -2
  16. package/src/cli/mcp/resource-handlers.ts +1 -1
  17. package/src/cli/mcp/server.test.ts +2 -2
  18. package/src/cli/mcp/server.ts +1 -1
  19. package/src/cli/registry.ts +23 -2
  20. package/src/codegen/fetch.test.ts +103 -2
  21. package/src/codegen/fetch.ts +62 -10
  22. package/src/config.ts +41 -0
  23. package/src/detectLexicon.test.ts +2 -2
  24. package/src/index.ts +2 -2
  25. package/src/lexicon-export.test.ts +92 -0
  26. package/src/lexicon.ts +88 -9
  27. package/src/lifecycle/change-set.test.ts +151 -0
  28. package/src/lifecycle/change-set.ts +172 -0
  29. package/src/{state → lifecycle}/git.test.ts +15 -15
  30. package/src/{state → lifecycle}/git.ts +14 -14
  31. package/src/{state → lifecycle}/index.ts +2 -0
  32. package/src/{state → lifecycle}/snapshot.test.ts +5 -5
  33. package/src/{state → lifecycle}/snapshot.ts +9 -9
  34. package/src/{state → lifecycle}/types.ts +1 -1
  35. package/src/op/activity-registry.test.ts +59 -0
  36. package/src/op/activity-registry.ts +98 -0
  37. package/src/op/builders.ts +56 -20
  38. package/src/op/index.ts +6 -1
  39. package/src/op/local-executor.test.ts +263 -0
  40. package/src/op/local-executor.ts +300 -0
  41. package/src/op/local-output.test.ts +54 -0
  42. package/src/op/local-output.ts +63 -0
  43. package/src/op/op.test.ts +41 -4
  44. package/src/op/types.ts +2 -2
  45. package/src/ownership.test.ts +109 -0
  46. package/src/ownership.ts +142 -0
  47. package/src/serializer.ts +19 -1
  48. package/src/toml-parse.ts +3 -3
  49. /package/src/{state → lifecycle}/digest.test.ts +0 -0
  50. /package/src/{state → lifecycle}/digest.ts +0 -0
  51. /package/src/{state → lifecycle}/live-diff.test.ts +0 -0
  52. /package/src/{state → lifecycle}/live-diff.ts +0 -0
@@ -4,6 +4,9 @@ import { createConnection } from "node:net";
4
4
  import { spawn as spawnChild, type ChildProcess } from "node:child_process";
5
5
  import { loadChantConfig } from "../../config";
6
6
  import { discoverOps } from "../../op/discover";
7
+ import { loadActivities, loadProfiles } from "../../op/activity-registry";
8
+ import { runOpLocally, findGate, LocalGateUnsupportedError, OpRunFailure } from "../../op/local-executor";
9
+ import { renderHuman, renderJson } from "../../op/local-output";
7
10
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
8
11
  import type { CommandContext } from "../registry";
9
12
  import {
@@ -25,6 +28,21 @@ function workflowFnName(opName: string): string {
25
28
  return kebabToCamel(opName) + "Workflow";
26
29
  }
27
30
 
31
+ /**
32
+ * Guard for Temporal-only commands (run list/status/log/signal/cancel, --report).
33
+ * These query durable run state that only exists under Temporal. Returns true
34
+ * when `--temporal` was passed; otherwise prints an actionable message and the
35
+ * caller should return non-zero.
36
+ */
37
+ function requireTemporalMode(ctx: CommandContext, what: string): boolean {
38
+ if (ctx.args.temporal) return true;
39
+ console.error(formatError({
40
+ message: `\`${what}\` is not available in local mode`,
41
+ hint: "pass --temporal or configure a profile",
42
+ }));
43
+ return false;
44
+ }
45
+
28
46
  export async function makeTemporalClient(profileName: string | undefined, projectPath: string) {
29
47
  const { config } = await loadChantConfig(projectPath);
30
48
  const profile = resolveProfile(config as Record<string, unknown>, profileName);
@@ -37,6 +55,7 @@ export async function makeTemporalClient(profileName: string | undefined, projec
37
55
  // ── chant run list ──────────────────────────────────���─────────────────────────
38
56
 
39
57
  export async function runOpList(ctx: CommandContext): Promise<number> {
58
+ if (!requireTemporalMode(ctx, "chant run list")) return 1;
40
59
  const { ops, errors } = await discoverOps();
41
60
 
42
61
  for (const err of errors) {
@@ -102,6 +121,7 @@ export async function runOpList(ctx: CommandContext): Promise<number> {
102
121
  // ── chant run status <name> ───────────────────────────────────────────────────
103
122
 
104
123
  export async function runOpStatus(ctx: CommandContext): Promise<number> {
124
+ if (!requireTemporalMode(ctx, "chant run status")) return 1;
105
125
  const name = ctx.args.extraPositional;
106
126
  if (!name) {
107
127
  console.error(formatError({ message: "Op name is required: chant run status <name>" }));
@@ -141,6 +161,7 @@ export async function runOpStatus(ctx: CommandContext): Promise<number> {
141
161
  // ── chant run log <name> ──────────────────────────────────────────────────────
142
162
 
143
163
  export async function runOpLog(ctx: CommandContext): Promise<number> {
164
+ if (!requireTemporalMode(ctx, "chant run log")) return 1;
144
165
  const name = ctx.args.extraPositional;
145
166
  if (!name) {
146
167
  console.error(formatError({ message: "Op name is required: chant run log <name>" }));
@@ -186,6 +207,7 @@ export async function runOpLog(ctx: CommandContext): Promise<number> {
186
207
  // ── chant run signal <name> <signal> ─────────────────────────────────────────
187
208
 
188
209
  export async function runOpSignal(ctx: CommandContext): Promise<number> {
210
+ if (!requireTemporalMode(ctx, "chant run signal")) return 1;
189
211
  const name = ctx.args.extraPositional;
190
212
  const signalName = ctx.args.extraPositional2;
191
213
 
@@ -212,6 +234,7 @@ export async function runOpSignal(ctx: CommandContext): Promise<number> {
212
234
  // ── chant run cancel <name> ───────────────────────────────────────────────────
213
235
 
214
236
  export async function runOpCancel(ctx: CommandContext): Promise<number> {
237
+ if (!requireTemporalMode(ctx, "chant run cancel")) return 1;
215
238
  const name = ctx.args.extraPositional;
216
239
  if (!name) {
217
240
  console.error(formatError({ message: "Op name is required: chant run cancel <name>" }));
@@ -274,7 +297,107 @@ function renderProgress(opName: string, history: WorkflowHistoryRaw): void {
274
297
  );
275
298
  }
276
299
 
300
+ /**
301
+ * `chant run <name>` dispatcher.
302
+ *
303
+ * Local mode is the default — it runs the Op in-process with no Temporal
304
+ * server. `--temporal` opts into a cluster (gates, schedules, durable resume).
305
+ * `--report` reads a past durable run and is therefore Temporal-only.
306
+ */
277
307
  export async function runOp(ctx: CommandContext): Promise<number> {
308
+ if (ctx.args.local && ctx.args.temporal) {
309
+ console.error(formatError({
310
+ message: "--local and --temporal are mutually exclusive",
311
+ hint: "omit both for local mode (the default), or pass exactly one",
312
+ }));
313
+ return 1;
314
+ }
315
+ if (ctx.args.report) {
316
+ if (!requireTemporalMode(ctx, "chant run --report")) return 1;
317
+ return runOpTemporal(ctx);
318
+ }
319
+ return ctx.args.temporal ? runOpTemporal(ctx) : runOpLocal(ctx);
320
+ }
321
+
322
+ /**
323
+ * Run an Op in-process via the local executor — no Temporal worker, server, or
324
+ * built `worker.ts`. Reads the Op config straight from discovery and resolves
325
+ * activities from the Temporal lexicon package.
326
+ */
327
+ export async function runOpLocal(ctx: CommandContext): Promise<number> {
328
+ const opName = ctx.args.path;
329
+ if (!opName || opName === ".") {
330
+ console.error(formatError({
331
+ message: "Op name is required: chant run <name>",
332
+ hint: "Run `chant run list --temporal` to see available Ops",
333
+ }));
334
+ return 1;
335
+ }
336
+
337
+ const { ops, errors } = await discoverOps();
338
+ for (const err of errors) console.error(formatWarning({ message: err }));
339
+
340
+ const discovered = ops.get(opName);
341
+ if (!discovered) {
342
+ const names = [...ops.keys()];
343
+ console.error(formatError({
344
+ message: `Op "${opName}" not found`,
345
+ hint: names.length > 0
346
+ ? `Available: ${names.join(", ")}`
347
+ : "No *.op.ts files found — create one",
348
+ }));
349
+ return 1;
350
+ }
351
+
352
+ const { config } = discovered;
353
+
354
+ // Pre-flight: gates/schedules need a durable runtime — fail before any step.
355
+ const gate = findGate(config);
356
+ if (gate) {
357
+ console.error(formatError({
358
+ message: new LocalGateUnsupportedError(gate.signalName).message,
359
+ }));
360
+ return 1;
361
+ }
362
+
363
+ let activities, profiles;
364
+ try {
365
+ [activities, profiles] = await Promise.all([loadActivities(), loadProfiles()]);
366
+ } catch (err) {
367
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
368
+ return 1;
369
+ }
370
+
371
+ // Ctrl-C aborts in-flight activities (kills their child processes) instead of
372
+ // orphaning them. The handler is removed in `finally` so it never leaks.
373
+ const controller = new AbortController();
374
+ const onSigint = () => {
375
+ console.error(formatWarning({ message: "interrupted — stopping Op" }));
376
+ controller.abort();
377
+ };
378
+ process.once("SIGINT", onSigint);
379
+
380
+ try {
381
+ const result = await runOpLocally(config, activities, profiles, controller.signal);
382
+ if (ctx.args.json) renderJson(result); else renderHuman(result);
383
+ return 0;
384
+ } catch (err) {
385
+ if (err instanceof OpRunFailure) {
386
+ if (ctx.args.json) renderJson(err.result); else renderHuman(err.result);
387
+ return 1;
388
+ }
389
+ if (err instanceof LocalGateUnsupportedError) {
390
+ console.error(formatError({ message: err.message }));
391
+ return 1;
392
+ }
393
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
394
+ return 1;
395
+ } finally {
396
+ process.removeListener("SIGINT", onSigint);
397
+ }
398
+ }
399
+
400
+ async function runOpTemporal(ctx: CommandContext): Promise<number> {
278
401
  const opName = ctx.args.path;
279
402
 
280
403
  if (!opName || opName === ".") {
@@ -187,6 +187,8 @@ describe("resolveCommand", () => {
187
187
  { name: "init", handler: noop },
188
188
  { name: "init lexicon", handler: noop },
189
189
  { name: "dev", handler: noop },
190
+ { name: "lifecycle plan", handler: noop },
191
+ { name: "lifecycle", handler: noop },
190
192
  ];
191
193
 
192
194
  function makeArgs(overrides: Partial<ParsedArgs>): ParsedArgs {
@@ -256,6 +258,18 @@ describe("resolveCommand", () => {
256
258
  expect(result!.compound).toBe(true);
257
259
  });
258
260
 
261
+ test("`lc` is an alias for `lifecycle` (compound)", () => {
262
+ const result = resolveCommand(makeArgs({ command: "lc", path: "plan" }), testRegistry);
263
+ expect(result!.def.name).toBe("lifecycle plan");
264
+ expect(result!.compound).toBe(true);
265
+ });
266
+
267
+ test("`lc` is an alias for `lifecycle` (simple)", () => {
268
+ const result = resolveCommand(makeArgs({ command: "lc" }), testRegistry);
269
+ expect(result!.def.name).toBe("lifecycle");
270
+ expect(result!.compound).toBe(false);
271
+ });
272
+
259
273
  test("resolves run status as compound command", () => {
260
274
  const registry: CommandDef[] = [
261
275
  { name: "run list", handler: noop },
package/src/cli/main.ts CHANGED
@@ -13,7 +13,7 @@ import { runServeLsp, runServeMcp, runServeUnknown } from "./handlers/serve";
13
13
  import { runInit, runInitLexicon } from "./handlers/init";
14
14
  import { runList, runImport, runUpdate, runDoctor } from "./handlers/misc";
15
15
  import { runMigrate } from "./handlers/migrate";
16
- import { runStateSnapshot, runStateShow, runStateDiff, runStateLog, runStateUnknown } from "./handlers/state";
16
+ import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecyclePlan, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
17
17
  import { runGraph } from "./handlers/graph";
18
18
  import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
19
19
 
@@ -37,6 +37,9 @@ export function parseArgs(args: string[]): ParsedArgs {
37
37
  help: false,
38
38
  profile: undefined,
39
39
  report: undefined,
40
+ local: undefined,
41
+ temporal: undefined,
42
+ json: undefined,
40
43
  live: false,
41
44
  migrateFrom: undefined,
42
45
  migrateTo: undefined,
@@ -46,6 +49,7 @@ export function parseArgs(args: string[]): ParsedArgs {
46
49
  useComposites: false,
47
50
  reportFile: undefined,
48
51
  skill: undefined,
52
+ src: undefined,
49
53
  };
50
54
 
51
55
  let i = 0;
@@ -85,7 +89,17 @@ export function parseArgs(args: string[]): ParsedArgs {
85
89
  } else if (arg === "--live") {
86
90
  result.live = true;
87
91
  } else if (arg === "--from") {
92
+ // Shared by `migrate --from <lexicon>` and `import --from <env>`; the
93
+ // two commands never run together, so one field carries both.
88
94
  result.migrateFrom = args[++i];
95
+ } else if (arg === "--type") {
96
+ result.selectType = args[++i];
97
+ } else if (arg === "--name") {
98
+ result.selectName = args[++i];
99
+ } else if (arg === "--owned") {
100
+ result.owned = true;
101
+ } else if (arg === "--verbatim") {
102
+ result.verbatim = true;
89
103
  } else if (arg === "--to") {
90
104
  result.migrateTo = args[++i];
91
105
  } else if (arg === "--emit") {
@@ -98,6 +112,14 @@ export function parseArgs(args: string[]): ParsedArgs {
98
112
  result.useComposites = true;
99
113
  } else if (arg === "--skill") {
100
114
  result.skill = args[++i];
115
+ } else if (arg === "--src") {
116
+ result.src = args[++i];
117
+ } else if (arg === "--local") {
118
+ result.local = true;
119
+ } else if (arg === "--temporal") {
120
+ result.temporal = true;
121
+ } else if (arg === "--json") {
122
+ result.json = true;
101
123
  } else if (!arg.startsWith("-")) {
102
124
  if (!result.command) {
103
125
  result.command = arg;
@@ -146,12 +168,14 @@ Ops:
146
168
 
147
169
  graph Show Op dependency graph
148
170
 
149
- State:
150
- state snapshot <env> Query API, save metadata to orphan branch
151
- state show <env> Show latest state snapshot
152
- state diff <env> Compare current build against last snapshot
153
- --live: query cloud now and detect drift
154
- state log [env] History of state snapshots
171
+ Lifecycle (alias: lc):
172
+ lifecycle snapshot <env> Query API, save metadata to orphan branch
173
+ lifecycle show <env> Show latest lifecycle snapshot
174
+ lifecycle diff <env> Compare current build against last snapshot
175
+ --live: query cloud now and detect drift
176
+ lifecycle plan <env> Typed change set (create/update/delete/adopt) vs live
177
+ --json: emit the ChangeSet as JSON
178
+ lifecycle log [env] History of lifecycle snapshots
155
179
 
156
180
  Lexicon development:
157
181
  dev generate Generate lexicon artifacts (+ validate + coverage)
@@ -182,6 +206,9 @@ Options:
182
206
  -v, --verbose Show stack traces on errors
183
207
  -h, --help Show this help message
184
208
  -p, --profile <name> Temporal worker profile to use (run command)
209
+ --local Run an Op with the local in-process executor (default)
210
+ --temporal Run an Op via a Temporal cluster (gates, schedules, durable resume)
211
+ --json Emit the structured run result as JSON (run command)
185
212
  --report Print deployment report instead of running (run command)
186
213
  OR with a path arg: SARIF report destination (migrate)
187
214
  --from <name> Source lexicon for migrate (default: github)
@@ -197,6 +224,7 @@ Examples:
197
224
  chant build ./infra/ --format yaml
198
225
  chant build ./infra/ --watch
199
226
  chant import template.json --output ./infra/
227
+ chant import --from prod --name my-bucket --output src/
200
228
  chant lint ./infra/
201
229
  chant lint ./infra/ --format sarif
202
230
  chant lint ./infra/ --watch
@@ -261,17 +289,18 @@ const registry: CommandDef[] = [
261
289
  { name: "graph", handler: runGraph },
262
290
 
263
291
  // State subcommands
264
- { name: "state snapshot", requiresPlugins: true, handler: runStateSnapshot },
265
- { name: "state show", handler: runStateShow },
266
- { name: "state diff", requiresPlugins: true, handler: runStateDiff },
267
- { name: "state log", handler: runStateLog },
292
+ { name: "lifecycle snapshot", requiresPlugins: true, handler: runLifecycleSnapshot },
293
+ { name: "lifecycle show", handler: runLifecycleShow },
294
+ { name: "lifecycle diff", requiresPlugins: true, handler: runLifecycleDiff },
295
+ { name: "lifecycle plan", requiresPlugins: true, handler: runLifecyclePlan },
296
+ { name: "lifecycle log", handler: runLifecycleLog },
268
297
 
269
298
  // Serve subcommands
270
299
  { name: "serve lsp", requiresPlugins: true, handler: runServeLsp },
271
300
  { name: "serve mcp", requiresPlugins: true, handler: runServeMcp },
272
301
 
273
302
  // Fallback for unknown subcommands (must come after compound entries)
274
- { name: "state", handler: runStateUnknown },
303
+ { name: "lifecycle", handler: runLifecycleUnknown },
275
304
  { name: "dev", handler: runDevUnknown },
276
305
  { name: "serve", handler: runServeUnknown },
277
306
  ];
@@ -306,9 +335,14 @@ async function main(): Promise<void> {
306
335
  process.exit(1);
307
336
  }
308
337
 
309
- // For compound commands (e.g. "run list"), args.path is the subcommand,
310
- // so always use "." as the project path. For simple commands, use args.path.
311
- const projectPath = match.compound ? (args.extraPositional || ".") : args.path;
338
+ // For compound commands (e.g. "run list", "lifecycle plan <env>"), the first
339
+ // positional is a subcommand argument an environment, op, or lexicon name
340
+ // not a project path. Plugins always load from the cwd; the handler reads its
341
+ // own positionals from args.extraPositional. Using extraPositional as the path
342
+ // here pointed plugin resolution at e.g. "./local" for `lifecycle plan local`,
343
+ // which then fell through to import-detection on an empty file set and failed
344
+ // with "No lexicon detected" even though chant.config.ts lists the lexicons.
345
+ const projectPath = match.compound ? "." : args.path;
312
346
  const plugins = match.def.requiresPlugins
313
347
  ? await loadPluginsOrExit(projectPath)
314
348
  : [];
@@ -1,23 +1,23 @@
1
1
  import { resolve } from "node:path";
2
2
  import type { LexiconPlugin } from "../../lexicon";
3
3
  import type { ToolDefinition, ToolHandler } from "./types";
4
- import { readSnapshot } from "../../state/git";
4
+ import { readSnapshot } from "../../lifecycle/git";
5
5
  import { build } from "../../build";
6
- import { computeBuildDigest, diffDigests } from "../../state/digest";
7
- import { takeSnapshot } from "../../state/snapshot";
8
- import type { StateSnapshot } from "../../state/types";
6
+ import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
7
+ import { takeSnapshot } from "../../lifecycle/snapshot";
8
+ import type { LifecycleSnapshot } from "../../lifecycle/types";
9
9
  export interface ToolRegistration {
10
10
  definition: ToolDefinition;
11
11
  handler: ToolHandler;
12
12
  }
13
13
 
14
14
  /**
15
- * Create state-snapshot tool definition and handler
15
+ * Create lifecycle-snapshot tool definition and handler
16
16
  */
17
17
  export function createSnapshotTool(plugins: LexiconPlugin[]): ToolRegistration {
18
18
  return {
19
19
  definition: {
20
- name: "state-snapshot",
20
+ name: "lifecycle-snapshot",
21
21
  description: "Capture deployed state for an environment",
22
22
  inputSchema: {
23
23
  type: "object",
@@ -46,12 +46,12 @@ export function createSnapshotTool(plugins: LexiconPlugin[]): ToolRegistration {
46
46
  }
47
47
 
48
48
  /**
49
- * Create state-diff tool definition and handler
49
+ * Create lifecycle-diff tool definition and handler
50
50
  */
51
51
  export function createDiffTool(plugins: LexiconPlugin[]): ToolRegistration {
52
52
  return {
53
53
  definition: {
54
- name: "state-diff",
54
+ name: "lifecycle-diff",
55
55
  description: "Compare current build declarations against last snapshot's digest",
56
56
  inputSchema: {
57
57
  type: "object",
@@ -75,7 +75,7 @@ export function createDiffTool(plugins: LexiconPlugin[]): ToolRegistration {
75
75
  const content = await readSnapshot(env, lex);
76
76
  let previousDigest = undefined;
77
77
  if (content) {
78
- const snapshot: StateSnapshot = JSON.parse(content);
78
+ const snapshot: LifecycleSnapshot = JSON.parse(content);
79
79
  previousDigest = snapshot.digest;
80
80
  }
81
81
  results[lex] = diffDigests(currentDigest, previousDigest);
@@ -3,7 +3,7 @@ import { discoverOps } from "../../op/discover";
3
3
  import { makeTemporalClient } from "../handlers/run";
4
4
  import { resolveWorkflowId } from "../handlers/run-client";
5
5
  import { generateReport } from "../handlers/run-report";
6
- import type { ToolRegistration } from "./state-tools";
6
+ import type { ToolRegistration } from "./lifecycle-tools";
7
7
 
8
8
  function workflowFnName(opName: string): string {
9
9
  return opName.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()) + "Workflow";
@@ -63,7 +63,7 @@ export function createOpRunTool(): ToolRegistration {
63
63
  definition: {
64
64
  name: "op-run",
65
65
  description:
66
- "Submit an Op workflow to Temporal. The worker must already be running — start it first with `chant run <name>`.",
66
+ "Submit an Op workflow to Temporal (Temporal mode). A Temporal worker must already be running — start it with `chant run <name> --temporal`.",
67
67
  inputSchema: {
68
68
  type: "object",
69
69
  properties: {
@@ -1,7 +1,7 @@
1
1
  import { resolve } from "node:path";
2
2
  import type { ResourceDefinition } from "./types";
3
3
  import { getContext } from "./resources/context";
4
- import { readSnapshot, readEnvironmentSnapshots } from "../../state/git";
4
+ import { readSnapshot, readEnvironmentSnapshots } from "../../lifecycle/git";
5
5
  import { discoverOps } from "../../op/discover";
6
6
  import { makeTemporalClient } from "../handlers/run";
7
7
  import { resolveWorkflowId } from "../handlers/run-client";
@@ -1133,9 +1133,9 @@ describe("McpServer", () => {
1133
1133
  const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools;
1134
1134
  expect(tools).toHaveLength(13);
1135
1135
  expect(tools.map((t) => t.name).sort()).toEqual([
1136
- "build", "explain", "import", "lint",
1136
+ "build", "explain", "import", "lifecycle-diff", "lifecycle-snapshot", "lint",
1137
1137
  "op-list", "op-report", "op-run", "op-signal", "op-status",
1138
- "scaffold", "search", "state-diff", "state-snapshot",
1138
+ "scaffold", "search",
1139
1139
  ]);
1140
1140
 
1141
1141
  const resourcesRes = await s.handleRequest({ jsonrpc: "2.0", id: 3, method: "resources/list" });
@@ -8,7 +8,7 @@ import { scaffoldTool, createScaffoldHandler } from "./tools/scaffold";
8
8
  import { searchTool, createSearchHandler } from "./tools/search";
9
9
  import type { LexiconPlugin } from "../../lexicon";
10
10
  import type { McpRequest, McpResponse, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
11
- import { createSnapshotTool, createDiffTool } from "./state-tools";
11
+ import { createSnapshotTool, createDiffTool } from "./lifecycle-tools";
12
12
  import { createOpListTool, createOpRunTool, createOpStatusTool, createOpSignalTool, createOpReportTool } from "./op-tools";
13
13
  import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
14
14
 
@@ -20,6 +20,12 @@ export interface ParsedArgs {
20
20
  help: boolean;
21
21
  profile?: string;
22
22
  report?: boolean;
23
+ /** `chant run` — force the local in-process executor (the default). */
24
+ local?: boolean;
25
+ /** `chant run` — run via a Temporal cluster instead of the local executor. */
26
+ temporal?: boolean;
27
+ /** `chant run` — emit the structured OpRunResult as JSON on stdout. */
28
+ json?: boolean;
23
29
  live: boolean;
24
30
  /** `chant migrate --from <name>` (default "github") */
25
31
  migrateFrom?: string;
@@ -37,6 +43,16 @@ export interface ParsedArgs {
37
43
  reportFile?: string;
38
44
  /** `chant init --skill <name>` filter (added in #95 commit) */
39
45
  skill?: string;
46
+ /** `chant import --type <ResourceType>` selector */
47
+ selectType?: string;
48
+ /** `chant import --name <name>` selector */
49
+ selectName?: string;
50
+ /** `chant import --owned` — restrict live import to chant-owned resources */
51
+ owned?: boolean;
52
+ /** `chant import --verbatim` — keep server-defaulted fields in live import */
53
+ verbatim?: boolean;
54
+ /** `chant lifecycle … --src <dir>` — build root override for lifecycle commands */
55
+ src?: string;
40
56
  }
41
57
 
42
58
  /**
@@ -75,16 +91,21 @@ export interface ResolvedCommand {
75
91
  * Supports compound commands like "dev generate" where args.command="dev"
76
92
  * and args.path="generate". Falls back to simple command matching.
77
93
  */
94
+ /** Short command aliases. `chant lc …` is sugar for `chant lifecycle …`. */
95
+ const COMMAND_ALIASES: Record<string, string> = { lc: "lifecycle" };
96
+
78
97
  export function resolveCommand(args: ParsedArgs, registry: CommandDef[]): ResolvedCommand | null {
98
+ const command = COMMAND_ALIASES[args.command] ?? args.command;
99
+
79
100
  // Try compound command first: "dev generate", "serve lsp", "init lexicon"
80
- const compound = `${args.command} ${args.path}`;
101
+ const compound = `${command} ${args.path}`;
81
102
  const compoundMatch = registry.find((c) => c.name === compound);
82
103
  if (compoundMatch) {
83
104
  return { def: compoundMatch, compound: true };
84
105
  }
85
106
 
86
107
  // Try simple command
87
- const simpleMatch = registry.find((c) => c.name === args.command);
108
+ const simpleMatch = registry.find((c) => c.name === command);
88
109
  if (simpleMatch) {
89
110
  return { def: simpleMatch, compound: false };
90
111
  }
@@ -1,5 +1,5 @@
1
- import { describe, test, expect } from "vitest";
2
- import { extractFromTar } from "./fetch";
1
+ import { describe, test, expect, vi, afterEach } from "vitest";
2
+ import { extractFromTar, fetchWithRetry } from "./fetch";
3
3
 
4
4
  /**
5
5
  * Build a minimal valid tar buffer with a single file entry.
@@ -117,3 +117,104 @@ describe("extractFromTar", () => {
117
117
  expect(files.get("multi.txt")!.toString()).toBe(content);
118
118
  });
119
119
  });
120
+
121
+ describe("fetchWithRetry", () => {
122
+ afterEach(() => {
123
+ vi.restoreAllMocks();
124
+ });
125
+
126
+ const ok = () => new Response("payload", { status: 200 });
127
+ const status = (code: number) => new Response("", { status: code });
128
+
129
+ test("returns immediately on a successful response", async () => {
130
+ const fetchMock = vi.fn().mockResolvedValue(ok());
131
+ vi.stubGlobal("fetch", fetchMock);
132
+
133
+ const resp = await fetchWithRetry("https://example.test/x", 4, 1);
134
+ expect(resp.ok).toBe(true);
135
+ expect(fetchMock).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ test("retries a transient status then succeeds", async () => {
139
+ const fetchMock = vi
140
+ .fn()
141
+ .mockResolvedValueOnce(status(504))
142
+ .mockResolvedValueOnce(status(503))
143
+ .mockResolvedValueOnce(ok());
144
+ vi.stubGlobal("fetch", fetchMock);
145
+
146
+ const resp = await fetchWithRetry("https://example.test/x", 4, 1);
147
+ expect(resp.ok).toBe(true);
148
+ expect(fetchMock).toHaveBeenCalledTimes(3);
149
+ });
150
+
151
+ test("retries a network error then succeeds", async () => {
152
+ const fetchMock = vi
153
+ .fn()
154
+ .mockRejectedValueOnce(new Error("ECONNRESET"))
155
+ .mockResolvedValueOnce(ok());
156
+ vi.stubGlobal("fetch", fetchMock);
157
+
158
+ const resp = await fetchWithRetry("https://example.test/x", 4, 1);
159
+ expect(resp.ok).toBe(true);
160
+ expect(fetchMock).toHaveBeenCalledTimes(2);
161
+ });
162
+
163
+ test("does not retry a permanent status", async () => {
164
+ const fetchMock = vi.fn().mockResolvedValue(status(404));
165
+ vi.stubGlobal("fetch", fetchMock);
166
+
167
+ await expect(fetchWithRetry("https://example.test/x", 4, 1)).rejects.toThrow("returned 404");
168
+ expect(fetchMock).toHaveBeenCalledTimes(1);
169
+ });
170
+
171
+ test("throws after exhausting retries on a transient status", async () => {
172
+ const fetchMock = vi.fn().mockResolvedValue(status(504));
173
+ vi.stubGlobal("fetch", fetchMock);
174
+
175
+ await expect(fetchWithRetry("https://example.test/x", 2, 1)).rejects.toThrow("returned 504");
176
+ // initial attempt + 2 retries
177
+ expect(fetchMock).toHaveBeenCalledTimes(3);
178
+ });
179
+
180
+ test("calls fetch with no init argument when none is given", async () => {
181
+ const fetchMock = vi.fn().mockResolvedValue(ok());
182
+ vi.stubGlobal("fetch", fetchMock);
183
+
184
+ await fetchWithRetry("https://example.test/x", 4, 1);
185
+ expect(fetchMock).toHaveBeenCalledWith("https://example.test/x");
186
+ });
187
+
188
+ test("passes request init through to fetch", async () => {
189
+ const fetchMock = vi.fn().mockResolvedValue(ok());
190
+ vi.stubGlobal("fetch", fetchMock);
191
+
192
+ const init = { headers: { Accept: "application/vnd.github+json" } };
193
+ await fetchWithRetry("https://example.test/x", 4, 1, init);
194
+ expect(fetchMock).toHaveBeenCalledWith("https://example.test/x", init);
195
+ });
196
+
197
+ test("preserves init across retries on a transient status", async () => {
198
+ const fetchMock = vi
199
+ .fn()
200
+ .mockResolvedValueOnce(status(503))
201
+ .mockResolvedValueOnce(ok());
202
+ vi.stubGlobal("fetch", fetchMock);
203
+
204
+ const init = { headers: { Accept: "application/vnd.github+json" } };
205
+ const resp = await fetchWithRetry("https://example.test/x", 4, 1, init);
206
+ expect(resp.ok).toBe(true);
207
+ expect(fetchMock).toHaveBeenCalledTimes(2);
208
+ expect(fetchMock).toHaveBeenNthCalledWith(1, "https://example.test/x", init);
209
+ expect(fetchMock).toHaveBeenNthCalledWith(2, "https://example.test/x", init);
210
+ });
211
+
212
+ test("does not retry a permanent status when init is given", async () => {
213
+ const fetchMock = vi.fn().mockResolvedValue(status(403));
214
+ vi.stubGlobal("fetch", fetchMock);
215
+
216
+ const init = { headers: { Accept: "application/vnd.github+json" } };
217
+ await expect(fetchWithRetry("https://example.test/x", 4, 1, init)).rejects.toThrow("returned 403");
218
+ expect(fetchMock).toHaveBeenCalledTimes(1);
219
+ });
220
+ });