@dench.com/cli 2.7.1 → 2.7.2

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/crm.ts CHANGED
@@ -90,8 +90,109 @@ type CrmCliContext = {
90
90
  sessionToken?: string;
91
91
  runId?: string;
92
92
  enrichmentGateway?: Pick<EnrichmentGatewayOptions, "apiKey" | "baseUrl">;
93
+ /** App origin used to build absolute entry deep links (e.g. https://www.dench.com). */
94
+ host?: string;
95
+ /** Workspace slug for absolute entry deep links (e.g. dench). */
96
+ workspaceSlug?: string;
93
97
  };
94
98
 
99
+ /**
100
+ * Canonical CRM entry deep links.
101
+ *
102
+ * Absolute (shareable): `https://…/<slug>?path=<object>&entry=<object>:<id>`
103
+ * Relative (in-app): `/?path=<object>&entry=<object>:<id>`
104
+ *
105
+ * Never invent `?object=` or a bare `entry=<id>` without the object prefix.
106
+ * `view` / `hidden` are optional UI chrome — not part of the canonical link.
107
+ */
108
+ export function buildCrmEntryDeepLink(args: {
109
+ host?: string;
110
+ workspaceSlug?: string;
111
+ objectName: string;
112
+ entryId: string;
113
+ }): { href: string; url?: string } {
114
+ const objectName = args.objectName.trim();
115
+ const entryId = args.entryId.trim();
116
+ // Keep a literal `:` between object and id (docs + in-app links); only
117
+ // encode the segments themselves so Convex ids stay readable.
118
+ const entryParam = `${encodeURIComponent(objectName)}:${encodeURIComponent(entryId)}`;
119
+ const pathParam = encodeURIComponent(objectName);
120
+ const href = `/?path=${pathParam}&entry=${entryParam}`;
121
+ const slug = args.workspaceSlug?.trim();
122
+ const host = args.host?.trim();
123
+ if (!slug || !host) {
124
+ return { href };
125
+ }
126
+ const withProtocol = /^https?:\/\//.test(host) ? host : `https://${host}`;
127
+ let base: string;
128
+ try {
129
+ base = new URL(withProtocol).origin;
130
+ } catch {
131
+ return { href };
132
+ }
133
+ return {
134
+ href,
135
+ url: `${base}/${encodeURIComponent(slug)}?path=${pathParam}&entry=${entryParam}`,
136
+ };
137
+ }
138
+
139
+ function entryIdFromUnknown(value: unknown): string | undefined {
140
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
141
+ return undefined;
142
+ }
143
+ const record = value as JsonRecord;
144
+ for (const key of ["entryId", "id"]) {
145
+ const candidate = record[key];
146
+ if (typeof candidate === "string" && candidate.trim()) {
147
+ return candidate.trim();
148
+ }
149
+ }
150
+ return undefined;
151
+ }
152
+
153
+ /**
154
+ * Canonical object name echoed by `entries.get` / `entries.create` /
155
+ * `batch.apply`. The server resolves display labels, case, and plurals
156
+ * (`lib/crm/resolveObject.ts`), so the name the caller typed is often NOT
157
+ * the one the workspace URL resolver matches on — it compares object names
158
+ * exactly. Always prefer this over the raw CLI argument.
159
+ */
160
+ function canonicalObjectNameFromUnknown(value: unknown): string | undefined {
161
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
162
+ return undefined;
163
+ }
164
+ const candidate = (value as JsonRecord).objectName;
165
+ return typeof candidate === "string" && candidate.trim()
166
+ ? candidate.trim()
167
+ : undefined;
168
+ }
169
+
170
+ function withCrmEntryDeepLinks(
171
+ ctx: CrmCliContext,
172
+ fallbackObjectName: string,
173
+ value: unknown,
174
+ ): unknown {
175
+ const entryId = entryIdFromUnknown(value);
176
+ if (!entryId || !value || typeof value !== "object" || Array.isArray(value)) {
177
+ return value;
178
+ }
179
+ // Older deployments don't echo `objectName`; the raw arg is the only
180
+ // thing left, and it's correct whenever the caller used the canonical name.
181
+ const objectName =
182
+ canonicalObjectNameFromUnknown(value) ?? fallbackObjectName;
183
+ const links = buildCrmEntryDeepLink({
184
+ host: ctx.host,
185
+ workspaceSlug: ctx.workspaceSlug,
186
+ objectName,
187
+ entryId,
188
+ });
189
+ return {
190
+ ...(value as JsonRecord),
191
+ href: links.href,
192
+ ...(links.url ? { url: links.url } : {}),
193
+ };
194
+ }
195
+
95
196
  // Adapt the shared helpers to throw CrmCliError so existing call sites
96
197
  // don't need to change their try/catch shape.
97
198
  class CrmCliError extends Error {}
@@ -291,18 +392,22 @@ async function applyBatchOpsInChunks(
291
392
  ops: CrmBatchOp[],
292
393
  idempotencyKey: string | undefined,
293
394
  allowUnknown = false,
294
- ): Promise<number> {
395
+ ): Promise<{ chunks: number; results: unknown[] }> {
295
396
  const chunks = chunkArray(ops, CRM_BATCH_CHUNK_SIZE);
397
+ const results: unknown[] = [];
296
398
  for (const [chunkIndex, chunk] of chunks.entries()) {
297
399
  const chunkKey = chunkIdempotencyKey(idempotencyKey, chunkIndex);
298
- await callMutation(ctx, api.batch.apply, {
400
+ const batchResult = (await callMutation(ctx, api.batch.apply, {
299
401
  ops: chunk,
300
402
  ...runContextArgs(ctx),
301
403
  ...(chunkKey ? { idempotencyKey: chunkKey } : {}),
302
404
  ...(allowUnknown ? { allowUnknown: true } : {}),
303
- });
405
+ })) as { results?: unknown[] } | null | undefined;
406
+ if (Array.isArray(batchResult?.results)) {
407
+ results.push(...batchResult.results);
408
+ }
304
409
  }
305
- return chunks.length;
410
+ return { chunks: chunks.length, results };
306
411
  }
307
412
 
308
413
  function runContextArgs(ctx: CrmCliContext): Record<string, string> {
@@ -568,6 +673,20 @@ const api = {
568
673
  deleteView: makeFunctionReference<"mutation">(
569
674
  "functions/crm/objects:deleteView",
570
675
  ),
676
+ setArchived: makeFunctionReference<"mutation">(
677
+ "functions/crm/objects:setArchived",
678
+ ),
679
+ setViewPinned: makeFunctionReference<"mutation">(
680
+ "functions/crm/objects:setViewPinned",
681
+ ),
682
+ },
683
+ sidebar: {
684
+ describe: makeFunctionReference<"query">(
685
+ "functions/workspaceNav:describeMySidebar",
686
+ ),
687
+ reposition: makeFunctionReference<"mutation">(
688
+ "functions/workspaceNav:repositionMyNavItem",
689
+ ),
571
690
  },
572
691
  fields: {
573
692
  list: makeFunctionReference<"query">("functions/crm/fields:list"),
@@ -672,6 +791,8 @@ export async function runCrmCommand(opts: {
672
791
  args: string[];
673
792
  sessionToken?: string;
674
793
  enrichmentGateway?: Pick<EnrichmentGatewayOptions, "apiKey" | "baseUrl">;
794
+ host?: string;
795
+ workspaceSlug?: string;
675
796
  }): Promise<void> {
676
797
  const args = [...opts.args];
677
798
  const jsonOutput = hasFlag(args, "--json");
@@ -682,6 +803,8 @@ export async function runCrmCommand(opts: {
682
803
  sessionToken: opts.sessionToken,
683
804
  runId: process.env.DENCH_RUN_ID?.trim() || undefined,
684
805
  enrichmentGateway: opts.enrichmentGateway,
806
+ host: opts.host,
807
+ workspaceSlug: opts.workspaceSlug,
685
808
  };
686
809
  const subcommand = args.shift();
687
810
  if (!subcommand || subcommand === "help" || subcommand === "--help") {
@@ -693,6 +816,8 @@ export async function runCrmCommand(opts: {
693
816
  return await runObjectsCommand(ctx);
694
817
  case "views":
695
818
  return await runViewsCommand(ctx);
819
+ case "sidebar":
820
+ return await runSidebarCommand(ctx);
696
821
  case "fields":
697
822
  return await runFieldsCommand(ctx);
698
823
  case "entries":
@@ -833,6 +958,50 @@ async function runCrmMembersCommand(ctx: CrmCliContext): Promise<void> {
833
958
  });
834
959
  }
835
960
 
961
+ /**
962
+ * `dench crm sidebar …` — the workspace app rail.
963
+ *
964
+ * Row VISIBILITY is team-shared and lives on the CRM records themselves
965
+ * (`crm objects archive`, `crm views pin/archive`). Row ORDER is a
966
+ * per-member preference, which is what `move` writes. `list` shows both
967
+ * halves: the visible rows with their 1-based positions, plus what's
968
+ * hidden and why.
969
+ */
970
+ async function runSidebarCommand(ctx: CrmCliContext): Promise<void> {
971
+ const verb = ctx.args.shift() ?? "list";
972
+ switch (verb) {
973
+ case "list": {
974
+ assertNoUnknownFlags(ctx.args, "crm sidebar list");
975
+ out(ctx, await callQuery(ctx, api.sidebar.describe, {}));
976
+ return;
977
+ }
978
+ case "move": {
979
+ const navId = shift(ctx.args, "nav id");
980
+ const rawPosition = shift(ctx.args, "position");
981
+ assertNoUnknownFlags(ctx.args, "crm sidebar move");
982
+ let position: string | number;
983
+ if (rawPosition === "top" || rawPosition === "bottom") {
984
+ position = rawPosition;
985
+ } else {
986
+ const parsed = Number.parseInt(rawPosition, 10);
987
+ if (!Number.isFinite(parsed) || parsed < 1) {
988
+ throw new CrmCliError(
989
+ `position must be "top", "bottom", or a positive integer, got ${rawPosition}`,
990
+ );
991
+ }
992
+ position = parsed;
993
+ }
994
+ out(
995
+ ctx,
996
+ await callMutation(ctx, api.sidebar.reposition, { navId, position }),
997
+ );
998
+ return;
999
+ }
1000
+ default:
1001
+ throw new CrmCliError(`Unknown crm sidebar verb: ${verb}`);
1002
+ }
1003
+ }
1004
+
836
1005
  async function runObjectsCommand(ctx: CrmCliContext): Promise<void> {
837
1006
  const verb = ctx.args.shift();
838
1007
  switch (verb) {
@@ -902,6 +1071,22 @@ async function runObjectsCommand(ctx: CrmCliContext): Promise<void> {
902
1071
  out(ctx, await callMutation(ctx, api.objects.remove, { name }));
903
1072
  return;
904
1073
  }
1074
+ // Archive is the sidebar's "remove from my rail", NOT a delete: the
1075
+ // table, its rows and every link to it keep working, the row just
1076
+ // moves into the rail's Archived flyout. Team-shared.
1077
+ case "archive":
1078
+ case "unarchive": {
1079
+ const name = shift(ctx.args, "object name");
1080
+ assertNoUnknownFlags(ctx.args, `crm objects ${verb}`);
1081
+ out(
1082
+ ctx,
1083
+ await callMutation(ctx, api.objects.setArchived, {
1084
+ name,
1085
+ archived: verb === "archive",
1086
+ }),
1087
+ );
1088
+ return;
1089
+ }
905
1090
  default:
906
1091
  throw new CrmCliError(`Unknown crm objects verb: ${verb ?? "<none>"}`);
907
1092
  }
@@ -1363,6 +1548,39 @@ async function runViewsCommand(ctx: CrmCliContext): Promise<void> {
1363
1548
  );
1364
1549
  return;
1365
1550
  }
1551
+ // Only `pinned` views render as workspace sidebar rows. Pinning also
1552
+ // clears any archive stamp, since a pinned-but-archived view still
1553
+ // wouldn't show up.
1554
+ case "pin":
1555
+ case "unpin": {
1556
+ const objectName = shift(ctx.args, "object name");
1557
+ const viewName = shift(ctx.args, "view name");
1558
+ assertNoUnknownFlags(ctx.args, `crm views ${verb}`);
1559
+ out(
1560
+ ctx,
1561
+ await callMutation(ctx, api.objects.setViewPinned, {
1562
+ objectName,
1563
+ viewName,
1564
+ pinned: verb === "pin",
1565
+ }),
1566
+ );
1567
+ return;
1568
+ }
1569
+ case "archive":
1570
+ case "unarchive": {
1571
+ const objectName = shift(ctx.args, "object name");
1572
+ const viewName = shift(ctx.args, "view name");
1573
+ assertNoUnknownFlags(ctx.args, `crm views ${verb}`);
1574
+ out(
1575
+ ctx,
1576
+ await callMutation(ctx, api.objects.setArchived, {
1577
+ name: objectName,
1578
+ viewName,
1579
+ archived: verb === "archive",
1580
+ }),
1581
+ );
1582
+ return;
1583
+ }
1366
1584
  default:
1367
1585
  throw new CrmCliError(`Unknown crm views verb: ${verb ?? "<none>"}`);
1368
1586
  }
@@ -1624,10 +1842,14 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1624
1842
  assertNoUnknownFlags(ctx.args, "crm entries get");
1625
1843
  out(
1626
1844
  ctx,
1627
- await callQuery(ctx, api.entries.get, {
1845
+ withCrmEntryDeepLinks(
1846
+ ctx,
1628
1847
  objectName,
1629
- entryId: entryId as any,
1630
- }),
1848
+ await callQuery(ctx, api.entries.get, {
1849
+ objectName,
1850
+ entryId: entryId as any,
1851
+ }),
1852
+ ),
1631
1853
  );
1632
1854
  return;
1633
1855
  }
@@ -1657,12 +1879,16 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1657
1879
  // pre-fetch — that would add a round-trip to every create.
1658
1880
  out(
1659
1881
  ctx,
1660
- await callMutation(ctx, api.entries.create, {
1882
+ withCrmEntryDeepLinks(
1883
+ ctx,
1661
1884
  objectName,
1662
- data,
1663
- ...runContextArgs(ctx),
1664
- ...(allowUnknown ? { allowUnknown: true } : {}),
1665
- }),
1885
+ await callMutation(ctx, api.entries.create, {
1886
+ objectName,
1887
+ data,
1888
+ ...runContextArgs(ctx),
1889
+ ...(allowUnknown ? { allowUnknown: true } : {}),
1890
+ }),
1891
+ ),
1666
1892
  );
1667
1893
  return;
1668
1894
  }
@@ -1680,7 +1906,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1680
1906
  }),
1681
1907
  );
1682
1908
  await assertKnownDataKeys(ctx, objectName, rows, allowUnknown);
1683
- const chunks = await applyBatchOpsInChunks(
1909
+ const { chunks, results } = await applyBatchOpsInChunks(
1684
1910
  ctx,
1685
1911
  rows.map((data) => ({
1686
1912
  type: "entries.create",
@@ -1690,7 +1916,41 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1690
1916
  idempotencyKey,
1691
1917
  allowUnknown,
1692
1918
  );
1693
- out(ctx, { created: rows.length, chunks });
1919
+ // Only claim per-row links when we actually got a result per row. A
1920
+ // silently-empty `results` is indistinguishable from "this object has
1921
+ // no links" and sends the agent right back to inventing URLs — the
1922
+ // exact failure this command's `url`/`href` output exists to prevent.
1923
+ const linked = results.length;
1924
+ out(ctx, {
1925
+ created: rows.length,
1926
+ chunks,
1927
+ ...(linked > 0
1928
+ ? {
1929
+ results: results.map((result) =>
1930
+ withCrmEntryDeepLinks(ctx, objectName, result),
1931
+ ),
1932
+ }
1933
+ : {}),
1934
+ ...(linked === rows.length
1935
+ ? {}
1936
+ : {
1937
+ linksUnavailable: true,
1938
+ // The recovery path has to be followable. With zero results
1939
+ // there is no entryId to hand to `entries get`, so point at
1940
+ // `entries list` (it returns `id` per row) instead — a hint
1941
+ // the agent can't act on just sends it back to guessing.
1942
+ linksHint:
1943
+ `Got ${linked} link(s) for ${rows.length} row(s). ` +
1944
+ (linked === 0
1945
+ ? `Run \`dench crm entries list ${objectName} --json\` to get each row's ` +
1946
+ `\`id\`, then \`dench crm entries get ${objectName} <id> --json\` for its ` +
1947
+ "canonical url"
1948
+ : `The rows above carry \`url\`/\`href\`; for the rest run ` +
1949
+ `\`dench crm entries list ${objectName} --json\` to get their \`id\`, then ` +
1950
+ `\`dench crm entries get ${objectName} <id> --json\``) +
1951
+ " — do not construct a url by hand.",
1952
+ }),
1953
+ });
1694
1954
  return;
1695
1955
  }
1696
1956
  case "update": {
@@ -1743,7 +2003,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1743
2003
  updates.map((update) => update.data),
1744
2004
  allowUnknown,
1745
2005
  );
1746
- const chunks = await applyBatchOpsInChunks(
2006
+ const { chunks } = await applyBatchOpsInChunks(
1747
2007
  ctx,
1748
2008
  updates.map((update) => ({
1749
2009
  type: "entries.update",
@@ -1874,7 +2134,7 @@ async function runCellsCommand(ctx: CrmCliContext): Promise<void> {
1874
2134
  data,
1875
2135
  }),
1876
2136
  );
1877
- const chunks = await applyBatchOpsInChunks(ctx, ops, idempotencyKey);
2137
+ const { chunks } = await applyBatchOpsInChunks(ctx, ops, idempotencyKey);
1878
2138
  out(ctx, { updated: updates.length, entriesUpdated: ops.length, chunks });
1879
2139
  return;
1880
2140
  }
@@ -3287,6 +3547,10 @@ Objects (CRM tables):
3287
3547
  command/API identifier (works on protected objects too).
3288
3548
  dench crm objects rename <from> <to>
3289
3549
  dench crm objects delete <name>
3550
+ dench crm objects archive <name> | unarchive <name>
3551
+ Hides/restores the object's workspace SIDEBAR row. Not a delete —
3552
+ the table, its rows and every link keep working; the row just moves
3553
+ into the rail's Archived flyout. Team-shared.
3290
3554
 
3291
3555
  Views (saved CRM subsets):
3292
3556
  dench crm views list <object> [--json]
@@ -3306,6 +3570,22 @@ Views (saved CRM subsets):
3306
3570
  dench crm views delete <object> <view-name>
3307
3571
  Idempotent; also clears the active-view pointer if it referenced
3308
3572
  the deleted view.
3573
+ dench crm views pin <object> <view-name> | unpin <object> <view-name>
3574
+ Only pinned views render as workspace sidebar rows. Pinning also
3575
+ clears any archive stamp.
3576
+ dench crm views archive <object> <view-name> | unarchive <object> <view-name>
3577
+ Moves the view's sidebar row into the Archived flyout. Unarchiving
3578
+ re-asserts the pin so the row comes back.
3579
+
3580
+ Sidebar (the workspace app rail):
3581
+ dench crm sidebar list [--json]
3582
+ Visible rows in order with 1-based positions, plus what's hidden
3583
+ from the rail and why (archived vs. an unpinned view).
3584
+ dench crm sidebar move <nav-id> <top|bottom|N>
3585
+ Reposition one row. Nav ids come from 'sidebar list' — e.g.
3586
+ 'crm-object:task', 'crm-view:task:Due%20today', 'crm-people',
3587
+ 'automation'. Row ORDER is per-member; row visibility (above) is
3588
+ team-shared.
3309
3589
 
3310
3590
  Fields (columns):
3311
3591
  dench crm fields list <object> [--json]
@@ -3368,6 +3648,18 @@ Entries (rows):
3368
3648
  being silently dropped. Verify names with 'crm fields list <object>'
3369
3649
  first. Pass --allow-unknown to write the raw value anyway, and on
3370
3650
  create the object's required fields must be present.
3651
+ create / get / create-many also return deep links when the
3652
+ workspace slug is known: \`url\` (absolute shareable) and \`href\`
3653
+ (relative in-app). Paste those — never invent \`?object=\` or a
3654
+ bare \`entry=<id>\` without the \`<object>:\` prefix. Canonical form:
3655
+ /<slug>?path=<object>&entry=<object>:<entryId>
3656
+ The link uses the object's CANONICAL name (echoed back as
3657
+ \`objectName\`), not the alias you typed — \`create Tasks\` resolves
3658
+ server-side but only \`task\` opens the record. If a response has no
3659
+ \`url\`/\`href\`, run 'crm entries get <object> <entryId> --json' for
3660
+ one. If create-many reports \`linksUnavailable\`, the unlinked rows
3661
+ have no id in that response — run 'crm entries list <object> --json'
3662
+ to recover their \`id\` first.
3371
3663
 
3372
3664
  Cells (single-field updates):
3373
3665
  dench crm cells get <object> <entryId> <field>
package/dench.ts CHANGED
@@ -4035,6 +4035,8 @@ async function main() {
4035
4035
  convex: runtime.client,
4036
4036
  args: subArgs,
4037
4037
  sessionToken: encodeDevCrmSessionToken(runtime.workspaceArgs),
4038
+ host: resolveApiHost(LOCAL_HOST),
4039
+ workspaceSlug: runtime.workspaceArgs.workspaceSlug,
4038
4040
  enrichmentGateway: gatewayAuth
4039
4041
  ? {
4040
4042
  apiKey: gatewayAuth.bearerToken,
@@ -4053,6 +4055,11 @@ async function main() {
4053
4055
  convex: authedRuntime.client,
4054
4056
  args: subArgs,
4055
4057
  sessionToken: authedRuntime.sessionToken,
4058
+ host: authedRuntime.host,
4059
+ workspaceSlug:
4060
+ authedRuntime.organization?.slug ??
4061
+ process.env.DENCH_ORG_SLUG?.trim() ??
4062
+ undefined,
4056
4063
  enrichmentGateway: gatewayAuth
4057
4064
  ? {
4058
4065
  apiKey: gatewayAuth.bearerToken,
@@ -2126,8 +2126,14 @@ export const responseSchemas: Record<string, ZodTypeAny> = {
2126
2126
  id: z.string(),
2127
2127
  role: z.enum(["user", "assistant", "system"]),
2128
2128
  createdAt: z.number(),
2129
+ updatedAt: z.number(),
2129
2130
  text: z.string(),
2130
2131
  runId: z.string().nullable(),
2132
+ status: z
2133
+ .enum(["streaming", "complete", "error", "canceled"])
2134
+ .nullable(),
2135
+ revision: z.number(),
2136
+ isDraft: z.boolean(),
2131
2137
  }),
2132
2138
  ),
2133
2139
  }),
@@ -3130,15 +3136,23 @@ export const responseExamples: Record<string, unknown> = {
3130
3136
  id: "cm_def456",
3131
3137
  role: "user",
3132
3138
  createdAt: 1765526400000,
3139
+ updatedAt: 1765526400000,
3133
3140
  text: "How do we deploy?",
3134
3141
  runId: null,
3142
+ status: "complete",
3143
+ revision: 0,
3144
+ isDraft: false,
3135
3145
  },
3136
3146
  {
3137
3147
  id: "cm_ghi789",
3138
3148
  role: "assistant",
3139
3149
  createdAt: 1765526460000,
3150
+ updatedAt: 1765526460000,
3140
3151
  text: "Push to main; Vercel auto-deploys.",
3141
3152
  runId: "rn_jkl012",
3153
+ status: "complete",
3154
+ revision: 1,
3155
+ isDraft: false,
3142
3156
  },
3143
3157
  ],
3144
3158
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.7.1",
3
+ "version": "2.7.2",
4
4
  "description": "Dench agent workspace CLI.",
5
5
  "type": "module",
6
6
  "bin": {