@lunnoa/client 2.5.0 → 3.0.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.
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/index.ts
4
- import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
5
- import { resolve as resolve4 } from "path";
4
+ import { mkdir as mkdir3, writeFile as writeFile3 } from "fs/promises";
5
+ import { resolve as resolve6 } from "path";
6
6
 
7
7
  // src/core/auth.ts
8
8
  var API_KEY_PREFIX = "lna_";
@@ -32,16 +32,21 @@ var LunnoaApiError = class extends Error {
32
32
  method;
33
33
  /** Path of the failed request (without the base URL). */
34
34
  path;
35
+ /** Present on definition-sync 409s when the server sends the current revision. */
36
+ currentRevision;
35
37
  constructor(args) {
36
38
  const detail = extractMessage(args.body);
39
+ const currentRevision = extractCurrentRevision(args.body);
40
+ const shown = [detail, currentRevision ? `currentRevision: ${currentRevision}` : void 0].filter(Boolean).join(" ");
37
41
  super(
38
- `Lunnoa API request failed: ${args.method} ${args.path} \u2192 ${args.status}${detail ? ` (${detail})` : ""}`
42
+ `Lunnoa API request failed: ${args.method} ${args.path} \u2192 ${args.status}${shown ? ` (${shown})` : ""}`
39
43
  );
40
44
  this.name = "LunnoaApiError";
41
45
  this.status = args.status;
42
46
  this.body = args.body;
43
47
  this.method = args.method;
44
48
  this.path = args.path;
49
+ this.currentRevision = currentRevision;
45
50
  }
46
51
  /** True for 401 responses (missing, revoked, or expired credential). */
47
52
  get isUnauthorized() {
@@ -60,6 +65,15 @@ var LunnoaApiError = class extends Error {
60
65
  return this.status === 429;
61
66
  }
62
67
  };
68
+ function publicApiMessage(error) {
69
+ const message = extractMessage(error.body) ?? error.message;
70
+ return error.currentRevision ? `${message} (currentRevision: ${error.currentRevision})` : message;
71
+ }
72
+ function extractCurrentRevision(body) {
73
+ if (!body || typeof body !== "object") return void 0;
74
+ const revision = body.currentRevision;
75
+ return typeof revision === "string" ? revision : void 0;
76
+ }
63
77
  function extractMessage(body) {
64
78
  if (body && typeof body === "object") {
65
79
  const record = body;
@@ -536,6 +550,121 @@ var ActionsResource = class {
536
550
  }
537
551
  };
538
552
 
553
+ // src/define/files.ts
554
+ var STEP_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
555
+ function hasFileAgentSignals(record) {
556
+ if (record.instructions && typeof record.instructions === "object") return true;
557
+ if (record.settings && typeof record.settings === "object") return true;
558
+ if ("workflows" in record || "agents" in record || "knowledge" in record || "variables" in record || "objects" in record || "objectTypes" in record || "mcpServers" in record || "a2aAgents" in record) {
559
+ return true;
560
+ }
561
+ return Array.isArray(record.tools) && record.tools.some(
562
+ (tool) => tool && typeof tool === "object" && !("kind" in tool) && !("use" in tool)
563
+ );
564
+ }
565
+ function isAgentFileShape(config) {
566
+ if (!config || typeof config !== "object") return false;
567
+ return hasFileAgentSignals(config);
568
+ }
569
+ function acceptAgentFile(config) {
570
+ const slug = config.slug?.trim();
571
+ if (!slug) throw new Error("defineAgent requires slug");
572
+ const instructions = config.instructions;
573
+ const fileRef = instructions && typeof instructions === "object" && typeof instructions.file === "string" && instructions.file.trim().length > 0;
574
+ if (typeof instructions !== "string" && !fileRef) {
575
+ throw new Error("defineAgent requires instructions");
576
+ }
577
+ const copy = { ...config };
578
+ delete copy.$schema;
579
+ delete copy.kind;
580
+ return { ...copy, kind: "agent", slug, instructions };
581
+ }
582
+ function assertStepKeys(steps, seen) {
583
+ for (const step of steps ?? []) {
584
+ if (!step?.use?.id?.trim()) {
585
+ throw new Error("workflow step requires use.id");
586
+ }
587
+ if (step.key !== void 0) {
588
+ if (!STEP_KEY_PATTERN.test(step.key) || step.key === "trigger") {
589
+ throw new Error(
590
+ `Step key "${step.key}" must start with a letter, use letters, digits, - or _, and not be "trigger".`
591
+ );
592
+ }
593
+ if (seen.has(step.key)) {
594
+ throw new Error(`Step key "${step.key}" is used twice.`);
595
+ }
596
+ seen.add(step.key);
597
+ }
598
+ for (const path of step.paths ?? []) {
599
+ assertStepKeys(path.steps ?? [], seen);
600
+ }
601
+ }
602
+ }
603
+ function acceptWorkflowStepsFile(config) {
604
+ const slug = config.slug?.trim();
605
+ if (!slug) throw new Error("defineWorkflow requires slug");
606
+ if (!config.steps?.length) {
607
+ throw new Error("defineWorkflow requires at least one step");
608
+ }
609
+ if (config.trigger && !config.trigger.id?.trim()) {
610
+ throw new Error("defineWorkflow trigger requires id");
611
+ }
612
+ assertStepKeys(config.steps, /* @__PURE__ */ new Set());
613
+ const copy = { ...config };
614
+ delete copy.$schema;
615
+ delete copy.kind;
616
+ return {
617
+ ...copy,
618
+ kind: "workflow",
619
+ format: "steps",
620
+ slug
621
+ };
622
+ }
623
+ function acceptWorkflowGraphFile(config) {
624
+ const slug = config.slug?.trim();
625
+ if (!slug) throw new Error("defineWorkflow requires slug");
626
+ if (!Array.isArray(config.nodes) || config.nodes.length === 0) {
627
+ throw new Error("defineWorkflow graph requires nodes");
628
+ }
629
+ const keys = /* @__PURE__ */ new Set();
630
+ let triggers = 0;
631
+ for (const node of config.nodes) {
632
+ if (!node?.key || typeof node.key !== "string") {
633
+ throw new Error("graph node requires key");
634
+ }
635
+ if (!STEP_KEY_PATTERN.test(node.key)) {
636
+ throw new Error(
637
+ `Node key "${node.key}" must start with a letter, use letters, digits, - or _, and not be empty.`
638
+ );
639
+ }
640
+ if (keys.has(node.key)) {
641
+ throw new Error(`Node key "${node.key}" is used twice.`);
642
+ }
643
+ keys.add(node.key);
644
+ if (node.nodeType === "trigger") triggers += 1;
645
+ }
646
+ if (triggers !== 1) {
647
+ throw new Error("defineWorkflow graph requires exactly one trigger");
648
+ }
649
+ for (const edge of config.edges ?? []) {
650
+ if (!keys.has(edge?.source) || !keys.has(edge?.target)) {
651
+ throw new Error(
652
+ `Edge from "${edge?.source ?? ""}" to "${edge?.target ?? ""}" references an unknown key.`
653
+ );
654
+ }
655
+ }
656
+ const copy = { ...config };
657
+ delete copy.$schema;
658
+ delete copy.kind;
659
+ return {
660
+ ...copy,
661
+ kind: "workflow",
662
+ format: "graph",
663
+ slug,
664
+ edges: config.edges ?? []
665
+ };
666
+ }
667
+
539
668
  // src/define/index.ts
540
669
  var DEFINE_KINDS = {
541
670
  connection: "connection",
@@ -673,10 +802,24 @@ function unwrapTool(tool) {
673
802
  }
674
803
  return tool;
675
804
  }
805
+ function syncFields(options) {
806
+ return {
807
+ ...options?.baseRevision ? { baseRevision: options.baseRevision } : {},
808
+ ...options?.force ? { force: true } : {}
809
+ };
810
+ }
811
+ function idsOf(links) {
812
+ if (!links) return void 0;
813
+ return links.map((link) => link.id);
814
+ }
676
815
  function agentDefinitionToUpsert(def, options) {
816
+ if (isAgentFileShape(def)) {
817
+ return fileAgentToUpsert(def, options);
818
+ }
677
819
  const tools = [];
678
820
  const workflowIds = [...options?.workflowIds ?? []];
679
- for (const tool of def.tools ?? []) {
821
+ const authored = def;
822
+ for (const tool of authored.tools ?? []) {
680
823
  const unwrapped = unwrapTool(tool);
681
824
  if (unwrapped.kind === DEFINE_KINDS.workflow) {
682
825
  continue;
@@ -692,17 +835,65 @@ function agentDefinitionToUpsert(def, options) {
692
835
  });
693
836
  }
694
837
  }
838
+ return {
839
+ slug: authored.slug,
840
+ name: authored.name,
841
+ description: authored.description,
842
+ instructions: authored.instructions,
843
+ model: authored.model,
844
+ aiConnectionId: authored.aiConnection?.id,
845
+ connectionIds: authored.connections?.map((c) => c.id).filter((id) => Boolean(id)),
846
+ workflowIds: workflowIds.length ? workflowIds : void 0,
847
+ tools,
848
+ codeSource: options?.codeSource ?? "generated",
849
+ ...syncFields(options)
850
+ };
851
+ }
852
+ function fileAgentToUpsert(def, options) {
853
+ if (typeof def.instructions !== "string") {
854
+ throw new Error(
855
+ "Agent instructions are still a file reference. The CLI resolves these before push."
856
+ );
857
+ }
858
+ const tools = [];
859
+ for (const tool of def.tools ?? []) {
860
+ const action = {
861
+ kind: DEFINE_KINDS.action,
862
+ id: tool.id,
863
+ appId: tool.appId,
864
+ connection: tool.connection?.id ? { kind: DEFINE_KINDS.connection, id: tool.connection.id } : void 0
865
+ };
866
+ tools.push({
867
+ key: tool.key,
868
+ appId: resolveActionAppId(action),
869
+ actionId: resolveActionId(action),
870
+ connectionId: tool.connection?.id,
871
+ name: tool.name,
872
+ description: tool.description,
873
+ input: tool.input
874
+ });
875
+ }
695
876
  return {
696
877
  slug: def.slug,
697
878
  name: def.name,
698
- description: def.description,
879
+ // A file is the whole definition. Omitted description and model are empty.
880
+ description: def.description ?? null,
699
881
  instructions: def.instructions,
700
- model: def.model,
882
+ model: def.model ?? null,
701
883
  aiConnectionId: def.aiConnection?.id,
702
- connectionIds: def.connections?.map((c) => c.id).filter((id) => Boolean(id)),
703
- workflowIds: workflowIds.length ? workflowIds : void 0,
704
- tools,
705
- managedByCode: true
884
+ ...def.settings ? { settings: def.settings } : {},
885
+ ...def.tools ? { tools } : {},
886
+ ...def.workflows ? { workflows: def.workflows } : {},
887
+ ...def.agents ? { agents: def.agents } : {},
888
+ ...def.connections ? { connectionIds: idsOf(def.connections) } : {},
889
+ ...def.knowledge ? { knowledgeIds: idsOf(def.knowledge) } : {},
890
+ ...def.variables ? { variableIds: idsOf(def.variables) } : {},
891
+ ...def.objects ? { objectIds: idsOf(def.objects) } : {},
892
+ ...def.objectTypes ? { objectTypeIds: idsOf(def.objectTypes) } : {},
893
+ ...def.mcpServers ? { mcpServers: def.mcpServers } : {},
894
+ ...def.a2aAgents ? { a2aAgentIds: idsOf(def.a2aAgents) } : {},
895
+ codeSource: "synced",
896
+ ...syncFields(options)
706
897
  };
707
898
  }
708
899
  var AgentsResource = class {
@@ -722,15 +913,22 @@ var AgentsResource = class {
722
913
  query: serializeListQuery(options)
723
914
  });
724
915
  }
916
+ /** Exported definition for `lunnoa/agents/<slug>.json`. */
917
+ getDefinition(slug) {
918
+ return this.http.get(
919
+ `/api/agents/slug/${encodeURIComponent(slug)}/definition`
920
+ );
921
+ }
725
922
  /**
726
923
  * Upsert an agent by slug inside the workspace on the API key.
727
924
  * Prefer the CLI (`npx @lunnoa/client agents deploy`) for happy-path deploy.
728
925
  */
729
926
  upsertBySlug(body, options) {
730
927
  const payload = "kind" in body && body.kind === "agent" ? agentDefinitionToUpsert(body, options) : body;
731
- return this.http.post("/api/agents/upsert", {
732
- body: payload
733
- });
928
+ return this.http.post(
929
+ "/api/agents/upsert",
930
+ { body: payload }
931
+ );
734
932
  }
735
933
  };
736
934
 
@@ -1527,7 +1725,7 @@ function computeDurationMs(start, end) {
1527
1725
  return b - a;
1528
1726
  }
1529
1727
  function defaultSleep(ms) {
1530
- return new Promise((resolve5) => setTimeout(resolve5, ms));
1728
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
1531
1729
  }
1532
1730
 
1533
1731
  // src/resources/knowledge.ts
@@ -1854,6 +2052,11 @@ function stepFromDefinition(step) {
1854
2052
  }
1855
2053
  function normalizeStartRunInput(input) {
1856
2054
  if (isWorkflowDefinition(input)) {
2055
+ if ("format" in input && input.format === "graph") {
2056
+ throw new Error(
2057
+ "runs.start runs straight lines only. Push the graph workflow, then execute it."
2058
+ );
2059
+ }
1857
2060
  return {
1858
2061
  steps: input.steps.map(stepFromDefinition),
1859
2062
  name: input.name ?? input.slug
@@ -2045,6 +2248,18 @@ var WorkflowAppsResource = class {
2045
2248
  }
2046
2249
  };
2047
2250
 
2251
+ // src/resources/code-sync.ts
2252
+ var CodeSyncResource = class {
2253
+ constructor(http) {
2254
+ this.http = http;
2255
+ }
2256
+ http;
2257
+ /** Workspace id and whether synced resources are locked in the UI. */
2258
+ context() {
2259
+ return this.http.get("/api/code-sync/context");
2260
+ }
2261
+ };
2262
+
2048
2263
  // src/resources/workflows.ts
2049
2264
  function triggerToUpsertInput(trigger) {
2050
2265
  return {
@@ -2057,8 +2272,38 @@ function triggerToUpsertInput(trigger) {
2057
2272
  ...trigger.description ? { description: trigger.description } : {}
2058
2273
  };
2059
2274
  }
2275
+ function asAction(use) {
2276
+ if (use && typeof use === "object" && "kind" in use && use.kind === DEFINE_KINDS.action) {
2277
+ return use;
2278
+ }
2279
+ const fileUse = use;
2280
+ return {
2281
+ kind: DEFINE_KINDS.action,
2282
+ id: fileUse.id,
2283
+ appId: fileUse.appId,
2284
+ connection: fileUse.connection?.id ? { kind: DEFINE_KINDS.connection, id: fileUse.connection.id } : void 0
2285
+ };
2286
+ }
2287
+ function asTrigger(trigger) {
2288
+ if ("kind" in trigger && trigger.kind === DEFINE_KINDS.trigger) {
2289
+ return trigger;
2290
+ }
2291
+ const fileTrigger = trigger;
2292
+ return {
2293
+ kind: DEFINE_KINDS.trigger,
2294
+ id: fileTrigger.id,
2295
+ appId: fileTrigger.appId,
2296
+ connection: fileTrigger.connection?.id ? { kind: DEFINE_KINDS.connection, id: fileTrigger.connection.id } : void 0,
2297
+ input: fileTrigger.input,
2298
+ inputs: fileTrigger.inputs,
2299
+ name: fileTrigger.name,
2300
+ description: fileTrigger.description
2301
+ };
2302
+ }
2060
2303
  function stepToUpsertInput(step) {
2061
- const use = step.use;
2304
+ const use = asAction(step.use);
2305
+ const retry = "retrySettings" in step ? step.retrySettings : void 0;
2306
+ const continueOnFailure = "continueOnFailure" in step ? step.continueOnFailure : void 0;
2062
2307
  return {
2063
2308
  ...step.key ? { key: step.key } : {},
2064
2309
  appId: resolveActionAppId(use),
@@ -2069,25 +2314,60 @@ function stepToUpsertInput(step) {
2069
2314
  ...step.input ?? {}
2070
2315
  },
2071
2316
  name: step.name ?? use.name,
2072
- description: use.description,
2317
+ description: step.description ?? use.description,
2318
+ ...retry ? { retrySettings: retry } : {},
2319
+ ...continueOnFailure ? { continueOnFailure } : {},
2073
2320
  ...step.paths?.length ? {
2074
2321
  paths: step.paths.map((path) => ({
2075
2322
  label: path.label,
2076
2323
  ...path.when ? { when: path.when } : {},
2077
- steps: path.steps.map(stepToUpsertInput)
2324
+ steps: path.steps.map(
2325
+ (nested) => stepToUpsertInput(nested)
2326
+ )
2078
2327
  }))
2079
2328
  } : {}
2080
2329
  };
2081
2330
  }
2082
- function workflowDefinitionToUpsert(def) {
2331
+ function syncFields2(options) {
2083
2332
  return {
2084
- slug: def.slug,
2085
- name: def.name,
2086
- description: def.description,
2087
- isActive: def.isActive,
2088
- ...def.trigger ? { trigger: triggerToUpsertInput(def.trigger) } : {},
2089
- managedByCode: true,
2090
- steps: def.steps.map(stepToUpsertInput)
2333
+ ...options?.baseRevision ? { baseRevision: options.baseRevision } : {},
2334
+ ...options?.force ? { force: true } : {}
2335
+ };
2336
+ }
2337
+ function workflowDefinitionToUpsert(def, options) {
2338
+ if ("format" in def && def.format === "graph") {
2339
+ return {
2340
+ slug: def.slug,
2341
+ name: def.name,
2342
+ // A file is the whole definition: an omitted description is empty, not "leave it".
2343
+ description: def.description ?? null,
2344
+ codeSource: "synced",
2345
+ graph: { nodes: def.nodes, edges: def.edges ?? [] },
2346
+ ...syncFields2(options)
2347
+ };
2348
+ }
2349
+ if ("format" in def && def.format === "steps") {
2350
+ const file = def;
2351
+ return {
2352
+ slug: file.slug,
2353
+ name: file.name,
2354
+ description: file.description ?? null,
2355
+ ...file.trigger ? { trigger: triggerToUpsertInput(asTrigger(file.trigger)) } : {},
2356
+ codeSource: "synced",
2357
+ steps: file.steps.map((step) => stepToUpsertInput(step)),
2358
+ ...syncFields2(options)
2359
+ };
2360
+ }
2361
+ const authored = def;
2362
+ return {
2363
+ slug: authored.slug,
2364
+ name: authored.name,
2365
+ description: authored.description,
2366
+ isActive: authored.isActive,
2367
+ ...authored.trigger ? { trigger: triggerToUpsertInput(authored.trigger) } : {},
2368
+ codeSource: options?.codeSource ?? "generated",
2369
+ ...syncFields2(options),
2370
+ steps: authored.steps.map((step) => stepToUpsertInput(step))
2091
2371
  };
2092
2372
  }
2093
2373
  var WorkflowsResource = class {
@@ -2112,15 +2392,22 @@ var WorkflowsResource = class {
2112
2392
  query: serializeListQuery(options)
2113
2393
  });
2114
2394
  }
2395
+ /** Exported definition for `lunnoa/workflows/<slug>.json`. */
2396
+ getDefinition(slug) {
2397
+ return this.http.get(
2398
+ `/api/workflows/slug/${encodeURIComponent(slug)}/definition`
2399
+ );
2400
+ }
2115
2401
  /**
2116
2402
  * Upsert a workflow by slug inside the workspace on the API key.
2117
2403
  * Prefer the CLI (`npx @lunnoa/client workflows deploy`) for happy-path deploy.
2118
2404
  */
2119
- upsertBySlug(body) {
2120
- const payload = "kind" in body && body.kind === "workflow" ? workflowDefinitionToUpsert(body) : body;
2121
- return this.http.post("/api/workflows/upsert", {
2122
- body: payload
2123
- });
2405
+ upsertBySlug(body, options) {
2406
+ const payload = "kind" in body && body.kind === "workflow" ? workflowDefinitionToUpsert(body, options) : body;
2407
+ return this.http.post(
2408
+ "/api/workflows/upsert",
2409
+ { body: payload }
2410
+ );
2124
2411
  }
2125
2412
  /**
2126
2413
  * Starts a new execution of the workflow with the given trigger inputs.
@@ -2186,6 +2473,8 @@ var LunnoaClient = class {
2186
2473
  aiConnections;
2187
2474
  workflowApps;
2188
2475
  discovery;
2476
+ /** Workspace id for the definitions lock, and the code-sync lock flag. */
2477
+ codeSync;
2189
2478
  constructor(options) {
2190
2479
  this.http = new HttpClient(options);
2191
2480
  this.auth = new AuthResource(this.http, this.http.tokenStore);
@@ -2209,6 +2498,7 @@ var LunnoaClient = class {
2209
2498
  this.connections = new ConnectionsResource(this.http);
2210
2499
  this.aiConnections = new AiConnectionsResource(this.http);
2211
2500
  this.discovery = new DiscoveryResource(this.http);
2501
+ this.codeSync = new CodeSyncResource(this.http);
2212
2502
  }
2213
2503
  };
2214
2504
 
@@ -2230,7 +2520,8 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
2230
2520
  "--access-token",
2231
2521
  "--out",
2232
2522
  "--app",
2233
- "--template"
2523
+ "--template",
2524
+ "--dir"
2234
2525
  ]);
2235
2526
  function requireFlagValue(flag, value) {
2236
2527
  if (!value || value.startsWith("--")) {
@@ -2244,6 +2535,8 @@ function parseArgs(argv) {
2244
2535
  out: "./src/lunnoa",
2245
2536
  json: false,
2246
2537
  forAgent: false,
2538
+ all: false,
2539
+ force: false,
2247
2540
  help: false
2248
2541
  };
2249
2542
  let i = 1;
@@ -2272,6 +2565,14 @@ function parseArgs(argv) {
2272
2565
  args.forAgent = true;
2273
2566
  continue;
2274
2567
  }
2568
+ if (flag === "--all") {
2569
+ args.all = true;
2570
+ continue;
2571
+ }
2572
+ if (flag === "--force") {
2573
+ args.force = true;
2574
+ continue;
2575
+ }
2275
2576
  if (flag && VALUE_FLAGS.has(flag)) {
2276
2577
  const value = requireFlagValue(flag, argv[i + 1]);
2277
2578
  switch (flag) {
@@ -2293,6 +2594,9 @@ function parseArgs(argv) {
2293
2594
  case "--template":
2294
2595
  args.template = value;
2295
2596
  break;
2597
+ case "--dir":
2598
+ args.dir = value;
2599
+ break;
2296
2600
  default:
2297
2601
  break;
2298
2602
  }
@@ -2331,6 +2635,12 @@ var USAGE = `Usage:
2331
2635
  npx @lunnoa/client workflows deploy <path> --url <url> --api-key <key>
2332
2636
  npx @lunnoa/client agents deploy <path> --url <url> --api-key <key>
2333
2637
  npx @lunnoa/client entity-types deploy <path> --url <url> --api-key <key>
2638
+ .json is pushed (code synced). .ts / .js / .mjs is deployed from TypeScript (code generated).
2639
+
2640
+ Definitions folder (default ./lunnoa, or definitionsDir in lunnoa.config.*, or --dir):
2641
+ npx @lunnoa/client pull [--all | <slug> | agents/<slug> | workflows/<slug>] [--force]
2642
+ npx @lunnoa/client push [<path|slug>] [--force]
2643
+ npx @lunnoa/client status [--json]
2334
2644
 
2335
2645
  Agent skills (copies this version's skills into .claude/skills and .cursor/skills):
2336
2646
  npx @lunnoa/client skills install [projectDir]
@@ -2344,6 +2654,9 @@ Options:
2344
2654
  --for-agent Only catalogue entries with availableForAgent
2345
2655
  --template init starter: GitHub owner/repo[#ref] or local folder (default: lunnoa-automate/lunnoa-starter)
2346
2656
  --json Machine-readable JSON (preferred for coding agents)
2657
+ --dir Definitions folder (default: ./lunnoa or definitionsDir in lunnoa.config)
2658
+ --all pull every agent and workflow in the workspace
2659
+ --force pull: overwrite a local edit; push: skip the revision check
2347
2660
  --help Show this help
2348
2661
 
2349
2662
  Environment: LUNNOA_URL, LUNNOA_API_KEY (also read from ./.env)
@@ -2360,6 +2673,682 @@ A slug already granted to other workspaces is not rewritten. Unknown instance at
2360
2673
  allowUnknownAttributes is true; use extensions.
2361
2674
  `;
2362
2675
 
2676
+ // src/cli/print.ts
2677
+ function printJson(value) {
2678
+ console.log(JSON.stringify(value, null, 2));
2679
+ }
2680
+ function formatTable(rows, columns) {
2681
+ if (rows.length === 0) {
2682
+ return "No results.";
2683
+ }
2684
+ const widths = columns.map((col) => {
2685
+ let width = col.header.length;
2686
+ for (const row of rows) {
2687
+ width = Math.max(width, stringifyCell(row[col.key]).length);
2688
+ }
2689
+ return width;
2690
+ });
2691
+ const header = columns.map((col, i) => pad(col.header, widths[i])).join(" ");
2692
+ const divider = widths.map((w) => "-".repeat(w)).join(" ");
2693
+ const body = rows.map(
2694
+ (row) => columns.map((col, i) => pad(stringifyCell(row[col.key]), widths[i])).join(" ")
2695
+ );
2696
+ return [header, divider, ...body].join("\n");
2697
+ }
2698
+ function printTable(rows, columns) {
2699
+ console.log(formatTable(rows, columns));
2700
+ }
2701
+ function stringifyCell(value) {
2702
+ if (value === null || value === void 0) {
2703
+ return "";
2704
+ }
2705
+ if (typeof value === "boolean") {
2706
+ return value ? "yes" : "no";
2707
+ }
2708
+ return String(value);
2709
+ }
2710
+ function pad(value, width) {
2711
+ return value.length >= width ? value : `${value}${" ".repeat(width - value.length)}`;
2712
+ }
2713
+
2714
+ // src/cli/sync/engine.ts
2715
+ import { resolve as resolve2 } from "path";
2716
+
2717
+ // src/cli/sync/diff.ts
2718
+ var SECRET_NAME = /authorization|token|secret|password|passwd|api[-_]?key|bearer|cookie|private[-_]?key/i;
2719
+ function findSecretFields(definition) {
2720
+ if (!definition || typeof definition !== "object") return [];
2721
+ const record = definition;
2722
+ const hits = [];
2723
+ if (Array.isArray(record.tools)) {
2724
+ record.tools.forEach((tool, index) => {
2725
+ walk(tool?.input, `tools[${index}].input`, hits);
2726
+ });
2727
+ }
2728
+ if (record.trigger && typeof record.trigger === "object") {
2729
+ walk(record.trigger.input, "trigger.input", hits);
2730
+ }
2731
+ if (Array.isArray(record.steps)) {
2732
+ record.steps.forEach((step, index) => walkStep(step, `steps[${index}]`, hits));
2733
+ }
2734
+ if (Array.isArray(record.nodes)) {
2735
+ record.nodes.forEach((node, index) => {
2736
+ walk(node?.value, `nodes[${index}].value`, hits);
2737
+ });
2738
+ }
2739
+ return hits;
2740
+ }
2741
+ function walkStep(step, path, hits) {
2742
+ if (!step || typeof step !== "object") return;
2743
+ const record = step;
2744
+ walk(record.input, `${path}.input`, hits);
2745
+ record.paths?.forEach((pathDef, index) => {
2746
+ pathDef?.steps?.forEach((nested, stepIndex) => {
2747
+ walkStep(nested, `${path}.paths[${index}].steps[${stepIndex}]`, hits);
2748
+ });
2749
+ });
2750
+ }
2751
+ function walk(value, path, hits) {
2752
+ if (Array.isArray(value)) {
2753
+ value.forEach((item, index) => walk(item, `${path}[${index}]`, hits));
2754
+ return;
2755
+ }
2756
+ if (!value || typeof value !== "object") return;
2757
+ for (const [key, child] of Object.entries(value)) {
2758
+ const childPath = `${path}.${key}`;
2759
+ if (typeof child === "string" && child.trim() && SECRET_NAME.test(key) && !child.includes("={{")) {
2760
+ hits.push(childPath);
2761
+ } else {
2762
+ walk(child, childPath, hits);
2763
+ }
2764
+ }
2765
+ }
2766
+ function redactSecrets(text) {
2767
+ return text.replace(
2768
+ /("(?:\\.|[^"\\])*(?:authorization|token|secret|password|passwd|api[-_]?key|bearer|cookie|private[-_]?key)(?:\\.|[^"\\])*")(\s*:\s*)"((?:\\.|[^"\\])*)"/gi,
2769
+ (match, key, colon, value) => value.includes("={{") ? match : `${key}${colon}"***"`
2770
+ );
2771
+ }
2772
+ function lineDiff(local, server) {
2773
+ const a = local.split("\n");
2774
+ const b = server.split("\n");
2775
+ if (a.length * b.length > 1e6) {
2776
+ const lines = a.map((line) => `-${line}`);
2777
+ lines.push("---");
2778
+ lines.push(...b.map((line) => `+${line}`));
2779
+ return lines.join("\n");
2780
+ }
2781
+ const dp = Array.from(
2782
+ { length: a.length + 1 },
2783
+ () => new Array(b.length + 1).fill(0)
2784
+ );
2785
+ for (let i2 = a.length - 1; i2 >= 0; i2 -= 1) {
2786
+ for (let j2 = b.length - 1; j2 >= 0; j2 -= 1) {
2787
+ dp[i2][j2] = a[i2] === b[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
2788
+ }
2789
+ }
2790
+ const out = [];
2791
+ let i = 0;
2792
+ let j = 0;
2793
+ while (i < a.length && j < b.length) {
2794
+ if (a[i] === b[j]) {
2795
+ out.push(` ${a[i]}`);
2796
+ i += 1;
2797
+ j += 1;
2798
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
2799
+ out.push(`-${a[i]}`);
2800
+ i += 1;
2801
+ } else {
2802
+ out.push(`+${b[j]}`);
2803
+ j += 1;
2804
+ }
2805
+ }
2806
+ while (i < a.length) out.push(`-${a[i++]}`);
2807
+ while (j < b.length) out.push(`+${b[j++]}`);
2808
+ return out.join("\n");
2809
+ }
2810
+ function formatConflict(label, local, server) {
2811
+ return `${label}
2812
+ --- local
2813
+ +++ server
2814
+ ${lineDiff(redactSecrets(local), redactSecrets(server))}`;
2815
+ }
2816
+
2817
+ // src/cli/sync/store.ts
2818
+ import { createHash } from "crypto";
2819
+ import { lstat, mkdir, readFile, readdir, realpath, writeFile } from "fs/promises";
2820
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
2821
+ var PathEscapeError = class extends Error {
2822
+ constructor(target) {
2823
+ super(`Refusing to write outside the definitions folder: ${target}`);
2824
+ this.name = "PathEscapeError";
2825
+ }
2826
+ };
2827
+ function isOutsideDefinitionsDir(root, target) {
2828
+ const rootAbs = resolve(root);
2829
+ const abs = resolve(rootAbs, target);
2830
+ const rel = relative(rootAbs, abs);
2831
+ return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
2832
+ }
2833
+ async function assertInsideDefinitionsDir(root, target) {
2834
+ const rootAbs = resolve(root);
2835
+ const abs = resolve(rootAbs, target);
2836
+ if (isOutsideDefinitionsDir(rootAbs, abs)) {
2837
+ throw new PathEscapeError(target);
2838
+ }
2839
+ let rootReal = rootAbs;
2840
+ try {
2841
+ rootReal = await realpath(rootAbs);
2842
+ } catch (error) {
2843
+ if (error.code !== "ENOENT") throw error;
2844
+ }
2845
+ const parts = relative(rootAbs, abs).split(sep).filter(Boolean);
2846
+ let cursor = rootAbs;
2847
+ for (const part of parts) {
2848
+ if (part === ".." || part === ".") throw new PathEscapeError(target);
2849
+ const next = resolve(cursor, part);
2850
+ let stats;
2851
+ try {
2852
+ stats = await lstat(next);
2853
+ } catch (error) {
2854
+ if (error.code === "ENOENT") return abs;
2855
+ throw error;
2856
+ }
2857
+ if (stats.isSymbolicLink()) {
2858
+ const real = await realpath(next);
2859
+ if (isOutsideDefinitionsDir(rootReal, real)) throw new PathEscapeError(target);
2860
+ cursor = real;
2861
+ continue;
2862
+ }
2863
+ cursor = next;
2864
+ }
2865
+ if (isOutsideDefinitionsDir(rootReal, cursor)) throw new PathEscapeError(target);
2866
+ return cursor;
2867
+ }
2868
+ async function writeGuarded(root, target, contents) {
2869
+ const abs = await assertInsideDefinitionsDir(root, target);
2870
+ await mkdir(dirname(abs), { recursive: true });
2871
+ await writeFile(abs, contents);
2872
+ }
2873
+ function hashDefinition(json, instructions) {
2874
+ const hash = createHash("sha256");
2875
+ hash.update(json);
2876
+ if (instructions !== void 0) {
2877
+ hash.update("\0");
2878
+ hash.update(instructions);
2879
+ }
2880
+ return hash.digest("hex");
2881
+ }
2882
+ function deploymentKey(baseUrl, workspaceId) {
2883
+ return `${baseUrl.replace(/\/+$/, "")}#${workspaceId}`;
2884
+ }
2885
+ function emptyLock() {
2886
+ return { version: 1, deployments: {} };
2887
+ }
2888
+ async function readLock(root) {
2889
+ try {
2890
+ const text = await readFile(resolve(root, "lunnoa.lock.json"), "utf8");
2891
+ const parsed = JSON.parse(text);
2892
+ if (!parsed || parsed.version !== 1 || !parsed.deployments || typeof parsed.deployments !== "object") {
2893
+ throw new Error("lunnoa.lock.json is not a version 1 lock file.");
2894
+ }
2895
+ return parsed;
2896
+ } catch (error) {
2897
+ if (error.code === "ENOENT") return emptyLock();
2898
+ throw error;
2899
+ }
2900
+ }
2901
+ async function writeLock(root, lock) {
2902
+ await writeGuarded(root, "lunnoa.lock.json", `${JSON.stringify(lock, null, 2)}
2903
+ `);
2904
+ }
2905
+ function lockBucket(lock, key) {
2906
+ const existing = lock.deployments[key] ?? { agents: {}, workflows: {} };
2907
+ existing.agents ??= {};
2908
+ existing.workflows ??= {};
2909
+ lock.deployments[key] = existing;
2910
+ return existing;
2911
+ }
2912
+ function schemaRelativePath(jsonAbsolutePath, kind, projectRoot) {
2913
+ const target = resolve(
2914
+ projectRoot,
2915
+ "node_modules",
2916
+ "@lunnoa",
2917
+ "client",
2918
+ "schemas",
2919
+ `${kind}.schema.json`
2920
+ );
2921
+ let rel = relative(dirname(jsonAbsolutePath), target).split(sep).join("/");
2922
+ if (!rel.startsWith(".")) rel = `./${rel}`;
2923
+ return rel;
2924
+ }
2925
+ function serializeDefinition(definition, schema) {
2926
+ const body = {};
2927
+ for (const [key, value] of Object.entries(definition)) {
2928
+ if (key === "$schema") continue;
2929
+ body[key] = value;
2930
+ }
2931
+ return `${JSON.stringify({ $schema: schema, ...body }, null, 2)}
2932
+ `;
2933
+ }
2934
+ function renderAgentFiles(definition, schema) {
2935
+ if (typeof definition.instructions !== "string") {
2936
+ throw new Error(
2937
+ "Cannot write an agent definition whose instructions are still a file reference."
2938
+ );
2939
+ }
2940
+ const instructionsFile = `${definition.slug}.instructions.md`;
2941
+ const json = serializeDefinition(
2942
+ { ...definition, instructions: { file: instructionsFile } },
2943
+ schema
2944
+ );
2945
+ return { json, instructions: definition.instructions, instructionsFile };
2946
+ }
2947
+ function renderWorkflowFile(definition, schema) {
2948
+ return serializeDefinition({ ...definition }, schema);
2949
+ }
2950
+ async function writeAgentDefinition(root, projectRoot, definition) {
2951
+ const jsonRel = join("agents", `${definition.slug}.json`);
2952
+ const schema = schemaRelativePath(resolve(root, jsonRel), "agent", projectRoot);
2953
+ const rendered = renderAgentFiles(definition, schema);
2954
+ await writeGuarded(root, jsonRel, rendered.json);
2955
+ await writeGuarded(root, join("agents", rendered.instructionsFile), rendered.instructions);
2956
+ return hashDefinition(rendered.json, rendered.instructions);
2957
+ }
2958
+ async function writeWorkflowDefinition(root, projectRoot, definition) {
2959
+ const jsonRel = join("workflows", `${definition.slug}.json`);
2960
+ const schema = schemaRelativePath(resolve(root, jsonRel), "workflow", projectRoot);
2961
+ const json = renderWorkflowFile(definition, schema);
2962
+ await writeGuarded(root, jsonRel, json);
2963
+ return hashDefinition(json);
2964
+ }
2965
+ async function readInside(root, rel) {
2966
+ const lexical = resolve(root, rel);
2967
+ if (isOutsideDefinitionsDir(root, lexical)) throw new PathEscapeError(rel);
2968
+ try {
2969
+ const abs = await assertInsideDefinitionsDir(root, rel);
2970
+ return await readFile(abs, "utf8");
2971
+ } catch (error) {
2972
+ if (error instanceof PathEscapeError) throw error;
2973
+ if (error.code === "ENOENT") return void 0;
2974
+ throw error;
2975
+ }
2976
+ }
2977
+ async function hashOnDisk(root, kind, slug) {
2978
+ if (kind === "workflow") {
2979
+ const text = await readInside(root, join("workflows", `${slug}.json`));
2980
+ return text === void 0 ? void 0 : hashDefinition(text);
2981
+ }
2982
+ const json = await readInside(root, join("agents", `${slug}.json`));
2983
+ if (json === void 0) return void 0;
2984
+ const parsed = JSON.parse(json);
2985
+ if (parsed.instructions && typeof parsed.instructions === "object" && typeof parsed.instructions.file === "string") {
2986
+ const instructions = await readInside(root, join("agents", parsed.instructions.file));
2987
+ return hashDefinition(json, instructions ?? "");
2988
+ }
2989
+ return hashDefinition(json);
2990
+ }
2991
+ async function listDefinitionFiles(root) {
2992
+ const out = [];
2993
+ for (const kind of ["workflow", "agent"]) {
2994
+ const folder = kind === "agent" ? "agents" : "workflows";
2995
+ let names;
2996
+ try {
2997
+ names = await readdir(resolve(root, folder));
2998
+ } catch (error) {
2999
+ if (error.code === "ENOENT") continue;
3000
+ throw error;
3001
+ }
3002
+ for (const name of names.sort()) {
3003
+ if (!name.endsWith(".json")) continue;
3004
+ out.push({
3005
+ kind,
3006
+ slug: name.slice(0, -".json".length),
3007
+ path: resolve(root, folder, name)
3008
+ });
3009
+ }
3010
+ }
3011
+ return out;
3012
+ }
3013
+ async function readLocalText(root, kind, slug) {
3014
+ const folder = kind === "agent" ? "agents" : "workflows";
3015
+ const json = await readInside(root, join(folder, `${slug}.json`));
3016
+ if (json === void 0) return void 0;
3017
+ if (kind === "workflow") return json;
3018
+ const parsed = JSON.parse(json);
3019
+ if (parsed.instructions && typeof parsed.instructions === "object" && typeof parsed.instructions.file === "string") {
3020
+ const md = await readInside(root, join("agents", parsed.instructions.file));
3021
+ return `${json}--- instructions ---
3022
+ ${md ?? ""}`;
3023
+ }
3024
+ return json;
3025
+ }
3026
+ async function loadDefinitionForPush(root, filePath) {
3027
+ let text;
3028
+ try {
3029
+ text = await readFile(filePath, "utf8");
3030
+ } catch (error) {
3031
+ if (error.code === "ENOENT") {
3032
+ throw new Error(`Definition file not found: ${filePath}`);
3033
+ }
3034
+ throw error;
3035
+ }
3036
+ let parsed;
3037
+ try {
3038
+ parsed = JSON.parse(text);
3039
+ } catch {
3040
+ throw new Error(`Invalid JSON in ${filePath}`);
3041
+ }
3042
+ if (parsed.kind === "agent") {
3043
+ const agent = acceptAgentFile(parsed);
3044
+ if (typeof agent.instructions === "object") {
3045
+ const mdPath = resolve(dirname(resolve(filePath)), agent.instructions.file);
3046
+ const boundary = isOutsideDefinitionsDir(root, resolve(filePath)) ? dirname(resolve(filePath)) : root;
3047
+ await assertInsideDefinitionsDir(boundary, mdPath);
3048
+ const instructions = await readFile(mdPath, "utf8");
3049
+ return { kind: "agent", definition: { ...agent, instructions } };
3050
+ }
3051
+ return { kind: "agent", definition: agent };
3052
+ }
3053
+ if (parsed.kind === "workflow" || parsed.format === "steps" || parsed.format === "graph") {
3054
+ if (parsed.format === "graph" || Array.isArray(parsed.nodes) && !parsed.steps) {
3055
+ return {
3056
+ kind: "workflow",
3057
+ definition: acceptWorkflowGraphFile(parsed)
3058
+ };
3059
+ }
3060
+ return {
3061
+ kind: "workflow",
3062
+ definition: acceptWorkflowStepsFile(parsed)
3063
+ };
3064
+ }
3065
+ throw new Error(`Definition JSON needs kind "agent" or "workflow": ${filePath}`);
3066
+ }
3067
+
3068
+ // src/cli/sync/engine.ts
3069
+ var SyncConflictError = class extends Error {
3070
+ constructor(message) {
3071
+ super(message);
3072
+ this.name = "SyncConflictError";
3073
+ }
3074
+ };
3075
+ function folderOf(kind) {
3076
+ return kind === "agent" ? "agents" : "workflows";
3077
+ }
3078
+ function labelOf(kind, slug) {
3079
+ return `${folderOf(kind)}/${slug}.json`;
3080
+ }
3081
+ async function fetchDefinition(client, kind, slug) {
3082
+ try {
3083
+ const body = kind === "agent" ? await client.agents.getDefinition(slug) : await client.workflows.getDefinition(slug);
3084
+ return {
3085
+ kind,
3086
+ id: body.id,
3087
+ slug: body.definition?.slug || body.slug,
3088
+ revision: body.revision,
3089
+ definition: body.definition
3090
+ };
3091
+ } catch (error) {
3092
+ if (error instanceof LunnoaApiError && error.isNotFound) return null;
3093
+ throw error;
3094
+ }
3095
+ }
3096
+ function warnSecrets(kind, slug, definition) {
3097
+ for (const field of findSecretFields(definition)) {
3098
+ console.error(
3099
+ `Warning: ${labelOf(kind, slug)} field "${field}" looks like a secret. Definitions are committed; keep secrets in Lunnoa connections.`
3100
+ );
3101
+ }
3102
+ }
3103
+ function serverText(root, projectRoot, remote) {
3104
+ const rel = labelOf(remote.kind, remote.slug);
3105
+ if (remote.kind === "agent") {
3106
+ const schema2 = schemaRelativePath(resolve2(root, rel), "agent", projectRoot);
3107
+ const rendered = renderAgentFiles(remote.definition, schema2);
3108
+ return `${rendered.json}--- instructions ---
3109
+ ${rendered.instructions}`;
3110
+ }
3111
+ const schema = schemaRelativePath(resolve2(root, rel), "workflow", projectRoot);
3112
+ return renderWorkflowFile(remote.definition, schema);
3113
+ }
3114
+ async function resolvePullTargets(client, lock, key, selector) {
3115
+ if (selector.scope === "tracked") {
3116
+ const dep = lock.deployments[key];
3117
+ return [
3118
+ ...Object.keys(dep?.workflows ?? {}).sort().map((slug) => ({ kind: "workflow", slug })),
3119
+ ...Object.keys(dep?.agents ?? {}).sort().map((slug) => ({ kind: "agent", slug }))
3120
+ ];
3121
+ }
3122
+ if (selector.scope === "all") {
3123
+ const [workflows, agents] = await Promise.all([
3124
+ client.workflows.list({ expansion: ["slug"] }),
3125
+ client.agents.list({ expansion: ["slug"] })
3126
+ ]);
3127
+ return [
3128
+ ...workflows.filter((workflow2) => workflow2.slug).map((workflow2) => ({ kind: "workflow", slug: workflow2.slug })),
3129
+ ...agents.filter((agent2) => agent2.slug).map((agent2) => ({ kind: "agent", slug: agent2.slug }))
3130
+ ];
3131
+ }
3132
+ if (selector.kind) return [{ kind: selector.kind, slug: selector.slug }];
3133
+ const [agent, workflow] = await Promise.all([
3134
+ fetchDefinition(client, "agent", selector.slug),
3135
+ fetchDefinition(client, "workflow", selector.slug)
3136
+ ]);
3137
+ if (agent && workflow) {
3138
+ throw new Error(
3139
+ `Slug "${selector.slug}" matches an agent and a workflow. Use agents/${selector.slug} or workflows/${selector.slug}.`
3140
+ );
3141
+ }
3142
+ if (agent) return [{ kind: "agent", slug: selector.slug }];
3143
+ if (workflow) return [{ kind: "workflow", slug: selector.slug }];
3144
+ throw new Error(`No agent or workflow with slug "${selector.slug}".`);
3145
+ }
3146
+ async function pullResources(request, selector) {
3147
+ const context = await request.client.codeSync.context();
3148
+ const key = deploymentKey(request.client.http.baseUrl, context.workspaceId);
3149
+ const lock = await readLock(request.definitionsDir);
3150
+ const targets = await resolvePullTargets(request.client, lock, key, selector);
3151
+ const bucket = lockBucket(lock, key);
3152
+ const writes = [];
3153
+ const skipped = [];
3154
+ const conflicts = [];
3155
+ for (const target of targets) {
3156
+ const remote = await fetchDefinition(request.client, target.kind, target.slug);
3157
+ if (!remote) {
3158
+ throw new Error(
3159
+ `${target.kind === "agent" ? "Agent" : "Workflow"} "${target.slug}" is missing in Lunnoa.`
3160
+ );
3161
+ }
3162
+ const entry = bucket[folderOf(target.kind)][remote.slug];
3163
+ const hash = await hashOnDisk(request.definitionsDir, target.kind, remote.slug);
3164
+ if (entry && entry.revision === remote.revision) {
3165
+ skipped.push(labelOf(target.kind, remote.slug));
3166
+ continue;
3167
+ }
3168
+ const localChanged = hash !== void 0 && (!entry || hash !== entry.fileHash);
3169
+ const serverChanged = !entry || entry.revision !== remote.revision;
3170
+ if (localChanged && serverChanged && !request.force) {
3171
+ warnSecrets(target.kind, remote.slug, remote.definition);
3172
+ const local = await readLocalText(request.definitionsDir, target.kind, remote.slug) ?? "";
3173
+ conflicts.push(
3174
+ formatConflict(
3175
+ labelOf(target.kind, remote.slug),
3176
+ local,
3177
+ serverText(request.definitionsDir, request.projectRoot, remote)
3178
+ )
3179
+ );
3180
+ continue;
3181
+ }
3182
+ writes.push(remote);
3183
+ }
3184
+ if (conflicts.length) {
3185
+ throw new SyncConflictError(
3186
+ `Local and server both changed. Run pull --force to overwrite.
3187
+
3188
+ ${conflicts.join("\n\n")}`
3189
+ );
3190
+ }
3191
+ const written = [];
3192
+ for (const remote of writes) {
3193
+ warnSecrets(remote.kind, remote.slug, remote.definition);
3194
+ const fileHash = remote.kind === "agent" ? await writeAgentDefinition(
3195
+ request.definitionsDir,
3196
+ request.projectRoot,
3197
+ remote.definition
3198
+ ) : await writeWorkflowDefinition(
3199
+ request.definitionsDir,
3200
+ request.projectRoot,
3201
+ remote.definition
3202
+ );
3203
+ bucket[folderOf(remote.kind)][remote.slug] = {
3204
+ id: remote.id,
3205
+ revision: remote.revision,
3206
+ fileHash
3207
+ };
3208
+ written.push(labelOf(remote.kind, remote.slug));
3209
+ }
3210
+ if (written.length) await writeLock(request.definitionsDir, lock);
3211
+ return { written, skipped };
3212
+ }
3213
+ async function resolvePushFiles(root, selector) {
3214
+ if (selector.scope === "all") {
3215
+ return (await listDefinitionFiles(root)).map((file) => ({
3216
+ kind: file.kind,
3217
+ path: file.path
3218
+ }));
3219
+ }
3220
+ if (selector.file) return [{ kind: selector.kind, path: resolve2(selector.file) }];
3221
+ if (selector.kind && selector.slug) {
3222
+ return [
3223
+ {
3224
+ kind: selector.kind,
3225
+ path: resolve2(root, folderOf(selector.kind), `${selector.slug}.json`)
3226
+ }
3227
+ ];
3228
+ }
3229
+ if (!selector.slug) throw new Error("Missing definition to push.");
3230
+ const found = (await listDefinitionFiles(root)).filter((file) => file.slug === selector.slug);
3231
+ if (found.length === 0) throw new Error(`No definition file for "${selector.slug}".`);
3232
+ if (found.length > 1) {
3233
+ throw new Error(
3234
+ `Slug "${selector.slug}" matches an agent and a workflow. Use agents/${selector.slug} or workflows/${selector.slug}.`
3235
+ );
3236
+ }
3237
+ return [{ kind: found[0].kind, path: found[0].path }];
3238
+ }
3239
+ async function pushResources(request, selector) {
3240
+ const context = await request.client.codeSync.context();
3241
+ const key = deploymentKey(request.client.http.baseUrl, context.workspaceId);
3242
+ const lock = await readLock(request.definitionsDir);
3243
+ const bucket = lockBucket(lock, key);
3244
+ const files = await resolvePushFiles(request.definitionsDir, selector);
3245
+ const pushed = [];
3246
+ for (const file of files) {
3247
+ const loaded = await loadDefinitionForPush(request.definitionsDir, file.path);
3248
+ if (file.kind && loaded.kind !== file.kind) {
3249
+ throw new Error(`Expected a ${file.kind} definition, found ${loaded.kind}.`);
3250
+ }
3251
+ const slug = loaded.definition.slug;
3252
+ const entry = bucket[folderOf(loaded.kind)][slug];
3253
+ const options = {
3254
+ codeSource: "synced",
3255
+ ...entry ? { baseRevision: entry.revision } : {},
3256
+ ...request.force ? { force: true } : {}
3257
+ };
3258
+ let id = "";
3259
+ let revision = "";
3260
+ let definition;
3261
+ try {
3262
+ if (loaded.kind === "workflow") {
3263
+ const result = await request.client.workflows.upsertBySlug(
3264
+ loaded.definition,
3265
+ options
3266
+ );
3267
+ id = result.id;
3268
+ revision = result.revision;
3269
+ definition = result.definition;
3270
+ } else {
3271
+ const result = await request.client.agents.upsertBySlug(
3272
+ loaded.definition,
3273
+ options
3274
+ );
3275
+ id = result.id;
3276
+ revision = result.revision;
3277
+ definition = result.definition;
3278
+ }
3279
+ } catch (error) {
3280
+ if (error instanceof LunnoaApiError && error.status === 409) {
3281
+ throw new Error(publicApiMessage(error));
3282
+ }
3283
+ throw error;
3284
+ }
3285
+ const fileHash = loaded.kind === "agent" ? await writeAgentDefinition(
3286
+ request.definitionsDir,
3287
+ request.projectRoot,
3288
+ definition
3289
+ ) : await writeWorkflowDefinition(
3290
+ request.definitionsDir,
3291
+ request.projectRoot,
3292
+ definition
3293
+ );
3294
+ bucket[folderOf(loaded.kind)][definition.slug || slug] = { id, revision, fileHash };
3295
+ await writeLock(request.definitionsDir, lock);
3296
+ pushed.push(labelOf(loaded.kind, definition.slug || slug));
3297
+ }
3298
+ return { pushed };
3299
+ }
3300
+ function localState(exists, hash, entry) {
3301
+ if (!exists) return "missing";
3302
+ if (!entry) return "new";
3303
+ return hash === entry.fileHash ? "unchanged" : "modified";
3304
+ }
3305
+ function remoteState(remote, entry) {
3306
+ if (!remote) return "missing in Lunnoa";
3307
+ if (entry && entry.revision === remote.revision) return "unchanged";
3308
+ return "changed in UI";
3309
+ }
3310
+ function isConflict(local, remote) {
3311
+ if (local === "new" && remote === "missing in Lunnoa") return false;
3312
+ if (local === "missing" && remote === "missing in Lunnoa") return false;
3313
+ return local !== "unchanged" && remote !== "unchanged";
3314
+ }
3315
+ async function statusResources(request) {
3316
+ const context = await request.client.codeSync.context();
3317
+ const key = deploymentKey(request.client.http.baseUrl, context.workspaceId);
3318
+ const lock = await readLock(request.definitionsDir);
3319
+ const dep = lock.deployments[key];
3320
+ const items = /* @__PURE__ */ new Map();
3321
+ for (const slug of Object.keys(dep?.workflows ?? {})) {
3322
+ items.set(`workflow:${slug}`, { kind: "workflow", slug });
3323
+ }
3324
+ for (const slug of Object.keys(dep?.agents ?? {})) {
3325
+ items.set(`agent:${slug}`, { kind: "agent", slug });
3326
+ }
3327
+ for (const file of await listDefinitionFiles(request.definitionsDir)) {
3328
+ items.set(`${file.kind}:${file.slug}`, { kind: file.kind, slug: file.slug });
3329
+ }
3330
+ const ordered = [...items.values()].sort((a, b) => {
3331
+ if (a.kind !== b.kind) return a.kind === "workflow" ? -1 : 1;
3332
+ return a.slug.localeCompare(b.slug);
3333
+ });
3334
+ const rows = [];
3335
+ for (const item of ordered) {
3336
+ const remote = await fetchDefinition(request.client, item.kind, item.slug);
3337
+ const hash = await hashOnDisk(request.definitionsDir, item.kind, item.slug);
3338
+ const entry = dep?.[folderOf(item.kind)]?.[item.slug];
3339
+ const local = localState(hash !== void 0, hash, entry);
3340
+ const remoteLabel = remoteState(remote, entry);
3341
+ rows.push({
3342
+ kind: item.kind,
3343
+ slug: item.slug,
3344
+ local,
3345
+ remote: remoteLabel,
3346
+ conflict: isConflict(local, remoteLabel)
3347
+ });
3348
+ }
3349
+ return rows;
3350
+ }
3351
+
2363
3352
  // src/cli/catalog.ts
2364
3353
  function asRecord(value) {
2365
3354
  if (value && typeof value === "object" && !Array.isArray(value)) {
@@ -2606,44 +3595,6 @@ function actionDetail(action) {
2606
3595
  };
2607
3596
  }
2608
3597
 
2609
- // src/cli/print.ts
2610
- function printJson(value) {
2611
- console.log(JSON.stringify(value, null, 2));
2612
- }
2613
- function formatTable(rows, columns) {
2614
- if (rows.length === 0) {
2615
- return "No results.";
2616
- }
2617
- const widths = columns.map((col) => {
2618
- let width = col.header.length;
2619
- for (const row of rows) {
2620
- width = Math.max(width, stringifyCell(row[col.key]).length);
2621
- }
2622
- return width;
2623
- });
2624
- const header = columns.map((col, i) => pad(col.header, widths[i])).join(" ");
2625
- const divider = widths.map((w) => "-".repeat(w)).join(" ");
2626
- const body = rows.map(
2627
- (row) => columns.map((col, i) => pad(stringifyCell(row[col.key]), widths[i])).join(" ")
2628
- );
2629
- return [header, divider, ...body].join("\n");
2630
- }
2631
- function printTable(rows, columns) {
2632
- console.log(formatTable(rows, columns));
2633
- }
2634
- function stringifyCell(value) {
2635
- if (value === null || value === void 0) {
2636
- return "";
2637
- }
2638
- if (typeof value === "boolean") {
2639
- return value ? "yes" : "no";
2640
- }
2641
- return String(value);
2642
- }
2643
- function pad(value, width) {
2644
- return value.length >= width ? value : `${value}${" ".repeat(width - value.length)}`;
2645
- }
2646
-
2647
3598
  // src/cli/discover.ts
2648
3599
  function requireTarget(args, label) {
2649
3600
  const value = args.path?.trim();
@@ -2884,13 +3835,13 @@ async function runConnectionsGet(client, args) {
2884
3835
  }
2885
3836
  async function runAgentsList(client, args) {
2886
3837
  const agents = await client.agents.list({
2887
- expansion: ["description", "slug", "managedByCode"]
3838
+ expansion: ["description", "slug", "codeSource"]
2888
3839
  });
2889
3840
  const rows = agents.map((agent) => ({
2890
3841
  id: agent.id,
2891
3842
  name: agent.name,
2892
3843
  slug: agent.slug ?? "",
2893
- managedByCode: Boolean(agent.managedByCode),
3844
+ codeSource: agent.codeSource ?? "none",
2894
3845
  description: agent.description ?? ""
2895
3846
  }));
2896
3847
  if (args.json) {
@@ -2901,19 +3852,19 @@ async function runAgentsList(client, args) {
2901
3852
  { key: "id", header: "ID" },
2902
3853
  { key: "slug", header: "SLUG" },
2903
3854
  { key: "name", header: "NAME" },
2904
- { key: "managedByCode", header: "CODE" }
3855
+ { key: "codeSource", header: "SOURCE" }
2905
3856
  ]);
2906
3857
  }
2907
3858
  async function runWorkflowsList(client, args) {
2908
3859
  const workflows = await client.workflows.list({
2909
- expansion: ["description", "isActive", "slug", "managedByCode"]
3860
+ expansion: ["description", "isActive", "slug", "codeSource"]
2910
3861
  });
2911
3862
  const rows = workflows.map((workflow) => ({
2912
3863
  id: workflow.id,
2913
3864
  name: workflow.name,
2914
3865
  slug: workflow.slug ?? "",
2915
3866
  isActive: Boolean(workflow.isActive),
2916
- managedByCode: Boolean(workflow.managedByCode)
3867
+ codeSource: workflow.codeSource ?? "none"
2917
3868
  }));
2918
3869
  if (args.json) {
2919
3870
  printJson(rows);
@@ -2924,7 +3875,7 @@ async function runWorkflowsList(client, args) {
2924
3875
  { key: "slug", header: "SLUG" },
2925
3876
  { key: "name", header: "NAME" },
2926
3877
  { key: "isActive", header: "ACTIVE" },
2927
- { key: "managedByCode", header: "CODE" }
3878
+ { key: "codeSource", header: "SOURCE" }
2928
3879
  ]);
2929
3880
  }
2930
3881
  async function runEntityTypesList(client, args) {
@@ -3400,7 +4351,7 @@ function typedAgentAccessor(client: LunnoaClient, agentId: string) {
3400
4351
 
3401
4352
  // src/cli/load-definition.ts
3402
4353
  import { pathToFileURL } from "url";
3403
- import { resolve } from "path";
4354
+ import { resolve as resolve3 } from "path";
3404
4355
  import { access } from "fs/promises";
3405
4356
  import { constants as fsConstants } from "fs";
3406
4357
  async function fileExists(path) {
@@ -3412,7 +4363,7 @@ async function fileExists(path) {
3412
4363
  }
3413
4364
  }
3414
4365
  async function loadDefinitionModule(pathArg) {
3415
- const absolute = resolve(process.cwd(), pathArg);
4366
+ const absolute = resolve3(process.cwd(), pathArg);
3416
4367
  if (!await fileExists(absolute)) {
3417
4368
  throw new Error(`Definition file not found: ${absolute}`);
3418
4369
  }
@@ -3468,21 +4419,22 @@ async function loadOptionalConfig() {
3468
4419
  "lunnoa.config.json"
3469
4420
  ];
3470
4421
  for (const name of candidates) {
3471
- const absolute = resolve(process.cwd(), name);
4422
+ const absolute = resolve3(process.cwd(), name);
3472
4423
  if (!await fileExists(absolute)) {
3473
4424
  continue;
3474
4425
  }
3475
4426
  if (name.endsWith(".json")) {
3476
- const { readFile: readFile2 } = await import("fs/promises");
3477
- const raw = await readFile2(absolute, "utf8");
4427
+ const { readFile: readFile3 } = await import("fs/promises");
4428
+ const raw = await readFile3(absolute, "utf8");
3478
4429
  const cfg2 = JSON.parse(raw);
3479
- return { url: cfg2.url, apiKey: cfg2.apiKey };
4430
+ return { url: cfg2.url, apiKey: cfg2.apiKey, definitionsDir: cfg2.definitionsDir };
3480
4431
  }
3481
4432
  const mod = await import(pathToFileURL(absolute).href);
3482
4433
  const cfg = mod.default ?? mod;
3483
4434
  return {
3484
4435
  url: cfg.url,
3485
- apiKey: cfg.apiKey
4436
+ apiKey: cfg.apiKey,
4437
+ definitionsDir: cfg.definitionsDir
3486
4438
  };
3487
4439
  }
3488
4440
  return {};
@@ -3490,8 +4442,8 @@ async function loadOptionalConfig() {
3490
4442
 
3491
4443
  // src/cli/init.ts
3492
4444
  import { spawn } from "child_process";
3493
- import { cp, mkdir, readdir, readFile, stat, writeFile } from "fs/promises";
3494
- import { basename, join, resolve as resolve2 } from "path";
4445
+ import { cp, mkdir as mkdir2, readdir as readdir2, readFile as readFile2, stat, writeFile as writeFile2 } from "fs/promises";
4446
+ import { basename, join as join2, resolve as resolve4 } from "path";
3495
4447
  import { createInterface } from "readline/promises";
3496
4448
  var DEFAULT_TEMPLATE = "lunnoa-automate/lunnoa-starter";
3497
4449
  var SKIP_LOCAL = /* @__PURE__ */ new Set(["node_modules", ".next", ".git", ".env", ".claude", ".cursor"]);
@@ -3544,14 +4496,14 @@ async function runInit(args) {
3544
4496
  if (!args.path) {
3545
4497
  throw new Error("Missing project directory. Example: init my-app --url https://lunnoa.example");
3546
4498
  }
3547
- const dest = resolve2(process.cwd(), args.path);
3548
- if ((await readdir(dest).catch(() => [])).length > 0) {
4499
+ const dest = resolve4(process.cwd(), args.path);
4500
+ if ((await readdir2(dest).catch(() => [])).length > 0) {
3549
4501
  throw new Error(`${dest} already exists and is not empty.`);
3550
4502
  }
3551
- await mkdir(dest, { recursive: true });
4503
+ await mkdir2(dest, { recursive: true });
3552
4504
  const template = args.template ?? DEFAULT_TEMPLATE;
3553
- if (await isDirectory(resolve2(template))) {
3554
- await cp(resolve2(template), dest, {
4505
+ if (await isDirectory(resolve4(template))) {
4506
+ await cp(resolve4(template), dest, {
3555
4507
  recursive: true,
3556
4508
  filter: (src) => !SKIP_LOCAL.has(basename(src))
3557
4509
  });
@@ -3566,9 +4518,9 @@ async function runInit(args) {
3566
4518
  apiKey ||= (await rl.question("API key lna_... (CLI only: discovery, codegen, deploy. Enter to skip): ")).trim();
3567
4519
  rl.close();
3568
4520
  }
3569
- const example = await readFile(join(dest, ".env.example"), "utf8").catch(() => "");
3570
- await writeFile(
3571
- join(dest, ".env"),
4521
+ const example = await readFile2(join2(dest, ".env.example"), "utf8").catch(() => "");
4522
+ await writeFile2(
4523
+ join2(dest, ".env"),
3572
4524
  setEnvValues(dropRetiredProjectEnv(example), {
3573
4525
  LUNNOA_URL: url,
3574
4526
  LUNNOA_API_KEY: apiKey
@@ -3590,18 +4542,18 @@ Next steps:`);
3590
4542
 
3591
4543
  // src/cli/skills.ts
3592
4544
  import { cp as cp2 } from "fs/promises";
3593
- import { dirname, resolve as resolve3 } from "path";
4545
+ import { dirname as dirname2, resolve as resolve5 } from "path";
3594
4546
  import { fileURLToPath } from "url";
3595
4547
  var SKILL_TARGETS = [".claude/skills", ".cursor/skills"];
3596
- var BUNDLED_SKILLS_DIR = resolve3(
3597
- dirname(fileURLToPath(import.meta.url)),
4548
+ var BUNDLED_SKILLS_DIR = resolve5(
4549
+ dirname2(fileURLToPath(import.meta.url)),
3598
4550
  "..",
3599
4551
  "skills"
3600
4552
  );
3601
4553
  async function installSkills(projectDir, sourceDir = BUNDLED_SKILLS_DIR) {
3602
4554
  const written = [];
3603
4555
  for (const target of SKILL_TARGETS) {
3604
- const dest = resolve3(projectDir, target);
4556
+ const dest = resolve5(projectDir, target);
3605
4557
  await cp2(sourceDir, dest, { recursive: true, force: true });
3606
4558
  written.push(dest);
3607
4559
  }
@@ -3611,6 +4563,11 @@ async function installSkills(projectDir, sourceDir = BUNDLED_SKILLS_DIR) {
3611
4563
  // src/cli/index.ts
3612
4564
  async function bindClient(args) {
3613
4565
  const fileConfig = await loadOptionalConfig();
4566
+ const projectRoot = process.cwd();
4567
+ const definitionsDir = resolve6(
4568
+ projectRoot,
4569
+ args.dir ?? fileConfig.definitionsDir ?? "lunnoa"
4570
+ );
3614
4571
  const url = args.url ?? process.env.LUNNOA_URL ?? fileConfig.url;
3615
4572
  const apiKey = args.apiKey ?? process.env.LUNNOA_API_KEY ?? fileConfig.apiKey;
3616
4573
  const accessToken = args.accessToken;
@@ -3624,9 +4581,114 @@ async function bindClient(args) {
3624
4581
  }
3625
4582
  return {
3626
4583
  client: new LunnoaClient({ baseUrl: url, apiKey, accessToken }),
3627
- url
4584
+ url,
4585
+ definitionsDir,
4586
+ projectRoot
3628
4587
  };
3629
4588
  }
4589
+ function resourceSelector(path) {
4590
+ const normalized = path.replace(/\\/g, "/");
4591
+ const file = /^(?:.*\/)?(agents|workflows)\/([^/]+)\.json$/.exec(normalized);
4592
+ if (file) {
4593
+ return {
4594
+ kind: file[1] === "agents" ? "agent" : "workflow",
4595
+ slug: file[2],
4596
+ file: path
4597
+ };
4598
+ }
4599
+ const prefixed = /^(agents|workflows)\/([^/]+)$/.exec(normalized);
4600
+ if (prefixed) {
4601
+ return {
4602
+ kind: prefixed[1] === "agents" ? "agent" : "workflow",
4603
+ slug: prefixed[2]
4604
+ };
4605
+ }
4606
+ if (normalized.endsWith(".json") || normalized.includes("/")) {
4607
+ return { slug: "", file: path };
4608
+ }
4609
+ return { slug: normalized };
4610
+ }
4611
+ function pullSelector(args) {
4612
+ if (args.all && args.path) {
4613
+ throw new Error("pull --all does not take a slug.");
4614
+ }
4615
+ if (args.all || !args.path) {
4616
+ return args.all ? { scope: "all" } : { scope: "tracked" };
4617
+ }
4618
+ const parsed = resourceSelector(args.path);
4619
+ if (parsed.file && !parsed.kind) {
4620
+ throw new Error("Pass a slug, agents/<slug>, or workflows/<slug>.");
4621
+ }
4622
+ return { scope: "one", kind: parsed.kind, slug: parsed.slug };
4623
+ }
4624
+ function pushSelector(args, deploy) {
4625
+ if (deploy) return { scope: "one", kind: deploy.kind, file: deploy.file };
4626
+ if (args.all && args.path) {
4627
+ throw new Error("push --all does not take a path.");
4628
+ }
4629
+ if (!args.path) return { scope: "all" };
4630
+ const parsed = resourceSelector(args.path);
4631
+ if (parsed.file) return { scope: "one", kind: parsed.kind, file: parsed.file };
4632
+ return { scope: "one", kind: parsed.kind, slug: parsed.slug };
4633
+ }
4634
+ async function runPull(args) {
4635
+ const bound = await bindClient(args);
4636
+ const report = await pullResources(
4637
+ {
4638
+ client: bound.client,
4639
+ definitionsDir: bound.definitionsDir,
4640
+ projectRoot: bound.projectRoot,
4641
+ force: args.force
4642
+ },
4643
+ pullSelector(args)
4644
+ );
4645
+ if (report.written.length === 0) {
4646
+ console.log("Nothing to pull.");
4647
+ return;
4648
+ }
4649
+ for (const file of report.written) console.log(`Pulled ${file}`);
4650
+ }
4651
+ async function runPush(args, deploy) {
4652
+ const bound = await bindClient(args);
4653
+ const report = await pushResources(
4654
+ {
4655
+ client: bound.client,
4656
+ definitionsDir: bound.definitionsDir,
4657
+ projectRoot: bound.projectRoot,
4658
+ force: args.force
4659
+ },
4660
+ pushSelector(args, deploy)
4661
+ );
4662
+ for (const file of report.pushed) console.log(`Pushed ${file}`);
4663
+ }
4664
+ async function runStatus(args) {
4665
+ const bound = await bindClient(args);
4666
+ const rows = await statusResources({
4667
+ client: bound.client,
4668
+ definitionsDir: bound.definitionsDir,
4669
+ projectRoot: bound.projectRoot
4670
+ });
4671
+ if (args.json) {
4672
+ printJson(rows);
4673
+ return;
4674
+ }
4675
+ printTable(
4676
+ rows.map((row) => ({
4677
+ kind: row.kind,
4678
+ slug: row.slug,
4679
+ local: row.local,
4680
+ remote: row.remote,
4681
+ conflict: row.conflict
4682
+ })),
4683
+ [
4684
+ { key: "kind", header: "KIND" },
4685
+ { key: "slug", header: "SLUG" },
4686
+ { key: "local", header: "LOCAL" },
4687
+ { key: "remote", header: "REMOTE" },
4688
+ { key: "conflict", header: "CONFLICT" }
4689
+ ]
4690
+ );
4691
+ }
3630
4692
  async function fetchDefinitions(client, baseUrl) {
3631
4693
  const entityTypes = await client.entityTypes.list({
3632
4694
  expansion: ["attributeSchema", "stateSchema", "description"]
@@ -3661,16 +4723,20 @@ async function runCodegen(args) {
3661
4723
  `Found ${definitions.entityTypes.length} entity type(s), ${definitions.workflows.length} workflow(s), ${definitions.agents.length} agent(s).`
3662
4724
  );
3663
4725
  const moduleSource = generateDeploymentModule(definitions);
3664
- const outDir = resolve4(process.cwd(), args.out);
3665
- await mkdir2(outDir, { recursive: true });
3666
- const outFile = resolve4(outDir, "lunnoa.generated.ts");
3667
- await writeFile2(outFile, moduleSource, "utf8");
4726
+ const outDir = resolve6(process.cwd(), args.out);
4727
+ await mkdir3(outDir, { recursive: true });
4728
+ const outFile = resolve6(outDir, "lunnoa.generated.ts");
4729
+ await writeFile3(outFile, moduleSource, "utf8");
3668
4730
  console.log(`Wrote ${outFile}`);
3669
4731
  }
3670
4732
  async function deployWorkflow(args) {
3671
4733
  if (!args.path) {
3672
4734
  throw new Error("Missing definition path. Example: workflows deploy ./workflow.ts");
3673
4735
  }
4736
+ if (args.path.endsWith(".json")) {
4737
+ await runPush(args, { kind: "workflow", file: args.path });
4738
+ return;
4739
+ }
3674
4740
  const { client } = await bindClient(args);
3675
4741
  const mod = await loadDefinitionModule(args.path);
3676
4742
  const def = pickWorkflowDefinition(mod);
@@ -3683,6 +4749,10 @@ async function deployAgent(args) {
3683
4749
  if (!args.path) {
3684
4750
  throw new Error("Missing definition path. Example: agents deploy ./agent.ts");
3685
4751
  }
4752
+ if (args.path.endsWith(".json")) {
4753
+ await runPush(args, { kind: "agent", file: args.path });
4754
+ return;
4755
+ }
3686
4756
  const { client } = await bindClient(args);
3687
4757
  const mod = await loadDefinitionModule(args.path);
3688
4758
  const def = pickAgentDefinition(mod);
@@ -3824,8 +4894,12 @@ async function runDiscovery(args) {
3824
4894
  }
3825
4895
  return false;
3826
4896
  }
3827
- async function main() {
3828
- const args = parseArgs(process.argv.slice(2));
4897
+ function reportCliError(error) {
4898
+ console.error(error instanceof Error ? error.message : error);
4899
+ process.exitCode = 1;
4900
+ }
4901
+ async function run(argv) {
4902
+ const args = parseArgs(argv);
3829
4903
  try {
3830
4904
  process.loadEnvFile();
3831
4905
  } catch {
@@ -3839,7 +4913,7 @@ async function main() {
3839
4913
  return;
3840
4914
  }
3841
4915
  if (args.command === "skills" && args.subcommand === "install") {
3842
- const written = await installSkills(resolve4(process.cwd(), args.path ?? "."));
4916
+ const written = await installSkills(resolve6(process.cwd(), args.path ?? "."));
3843
4917
  for (const dest of written) {
3844
4918
  console.log(`Installed Lunnoa skills into ${dest}`);
3845
4919
  }
@@ -3849,6 +4923,18 @@ async function main() {
3849
4923
  await runCodegen(args);
3850
4924
  return;
3851
4925
  }
4926
+ if (args.command === "pull") {
4927
+ await runPull(args);
4928
+ return;
4929
+ }
4930
+ if (args.command === "push") {
4931
+ await runPush(args);
4932
+ return;
4933
+ }
4934
+ if (args.command === "status") {
4935
+ await runStatus(args);
4936
+ return;
4937
+ }
3852
4938
  if (await runDiscovery(args)) {
3853
4939
  return;
3854
4940
  }
@@ -3869,7 +4955,17 @@ async function main() {
3869
4955
  console.log(USAGE);
3870
4956
  process.exitCode = 1;
3871
4957
  }
3872
- main().catch((error) => {
3873
- console.error(error instanceof Error ? error.message : error);
3874
- process.exitCode = 1;
3875
- });
4958
+ async function main() {
4959
+ try {
4960
+ await run(process.argv.slice(2));
4961
+ } catch (error) {
4962
+ reportCliError(error);
4963
+ }
4964
+ }
4965
+ if (process.env.VITEST !== "true") {
4966
+ void main();
4967
+ }
4968
+ export {
4969
+ reportCliError,
4970
+ run
4971
+ };