@dench.com/cli 2.0.4 → 2.1.1

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
@@ -48,12 +48,16 @@ import {
48
48
  getRequiredFieldsForApolloPath,
49
49
  } from "./lib/crm-enrichment";
50
50
  import {
51
+ type AviatoEnrichCompanyBody,
52
+ type AviatoEnrichPersonBody,
51
53
  aviatoEnrichCompany,
52
54
  aviatoEnrichPerson,
55
+ type EnrichCompanyBody,
53
56
  EnrichmentGatewayError,
54
57
  type EnrichmentGatewayOptions,
58
+ type EnrichPersonBody,
55
59
  enrichCompany,
56
- enrichPersonByIdentifier,
60
+ enrichPerson,
57
61
  } from "./lib/enrichment-gateway";
58
62
 
59
63
  // Contact-info fields that Aviato does not return.
@@ -90,6 +94,32 @@ type CrmCliContext = {
90
94
  // don't need to change their try/catch shape.
91
95
  class CrmCliError extends Error {}
92
96
 
97
+ function userFacingConvexErrorMessage(error: unknown): string | null {
98
+ if (!error || typeof error !== "object") return null;
99
+ const data = (error as { data?: unknown }).data;
100
+ if (typeof data === "string" && data.trim().length > 0) {
101
+ return data.trim();
102
+ }
103
+ if (data && typeof data === "object" && !Array.isArray(data)) {
104
+ const record = data as Record<string, unknown>;
105
+ for (const key of ["message", "error", "reason"]) {
106
+ const value = record[key];
107
+ if (typeof value === "string" && value.trim().length > 0) {
108
+ return value.trim();
109
+ }
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function normalizeRpcError(error: unknown): Error {
116
+ if (error instanceof CrmCliError) return error;
117
+ return new CrmCliError(
118
+ userFacingConvexErrorMessage(error) ??
119
+ (error instanceof Error ? error.message : String(error)),
120
+ );
121
+ }
122
+
93
123
  function shift(args: string[], expected: string): string {
94
124
  try {
95
125
  return shiftRaw(args, expected);
@@ -275,10 +305,14 @@ async function callQuery(
275
305
  fn: Parameters<ConvexHttpClient["query"]>[0],
276
306
  args: Record<string, unknown> = {},
277
307
  ): Promise<unknown> {
278
- return ctx.convex.query(fn, {
279
- ...args,
280
- ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
281
- } as never);
308
+ try {
309
+ return await ctx.convex.query(fn, {
310
+ ...args,
311
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
312
+ } as never);
313
+ } catch (error) {
314
+ throw normalizeRpcError(error);
315
+ }
282
316
  }
283
317
 
284
318
  async function callMutation(
@@ -286,10 +320,14 @@ async function callMutation(
286
320
  fn: Parameters<ConvexHttpClient["mutation"]>[0],
287
321
  args: Record<string, unknown> = {},
288
322
  ): Promise<unknown> {
289
- return ctx.convex.mutation(fn, {
290
- ...args,
291
- ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
292
- } as never);
323
+ try {
324
+ return await ctx.convex.mutation(fn, {
325
+ ...args,
326
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
327
+ } as never);
328
+ } catch (error) {
329
+ throw normalizeRpcError(error);
330
+ }
293
331
  }
294
332
 
295
333
  function out(ctx: CrmCliContext, value: unknown): void {
@@ -383,6 +421,21 @@ const api = {
383
421
  update: makeFunctionReference<"mutation">("functions/crm/fields:update"),
384
422
  remove: makeFunctionReference<"mutation">("functions/crm/fields:remove"),
385
423
  reorder: makeFunctionReference<"mutation">("functions/crm/fields:reorder"),
424
+ reorderEnumValues: makeFunctionReference<"mutation">(
425
+ "functions/crm/fields:reorderEnumValues",
426
+ ),
427
+ addEnumValue: makeFunctionReference<"mutation">(
428
+ "functions/crm/fields:addEnumValue",
429
+ ),
430
+ removeEnumValue: makeFunctionReference<"mutation">(
431
+ "functions/crm/fields:removeEnumValue",
432
+ ),
433
+ setEnumColor: makeFunctionReference<"mutation">(
434
+ "functions/crm/fields:setEnumColor",
435
+ ),
436
+ renameEnumValue: makeFunctionReference<"mutation">(
437
+ "functions/crm/fields:renameEnumValue",
438
+ ),
386
439
  },
387
440
  entries: {
388
441
  list: makeFunctionReference<"query">("functions/crm/entries:list"),
@@ -693,6 +746,107 @@ async function runFieldsCommand(ctx: CrmCliContext): Promise<void> {
693
746
  );
694
747
  return;
695
748
  }
749
+ case "enum-reorder": {
750
+ const objectName = shift(ctx.args, "object name");
751
+ const fieldName = shift(ctx.args, "field name");
752
+ // Same pattern as `reorder`: peel positional args after asserting
753
+ // there are no stray flags so a typo'd `--limit` fails loud
754
+ // instead of being silently consumed as an enum value.
755
+ assertNoUnknownFlags(ctx.args, "crm fields enum-reorder");
756
+ const orderedValues = [...ctx.args];
757
+ if (orderedValues.length === 0) {
758
+ throw new CrmCliError(
759
+ "dench crm fields enum-reorder requires at least one value. " +
760
+ 'Pass each enum value as a positional arg: `enum-reorder task Status "Done" "In Progress" "To Do"`',
761
+ );
762
+ }
763
+ out(
764
+ ctx,
765
+ await callMutation(ctx, api.fields.reorderEnumValues, {
766
+ objectName,
767
+ fieldName,
768
+ orderedValues,
769
+ }),
770
+ );
771
+ return;
772
+ }
773
+ case "enum-add": {
774
+ const objectName = shift(ctx.args, "object name");
775
+ const fieldName = shift(ctx.args, "field name");
776
+ const value = shift(ctx.args, "enum value");
777
+ const color = getFlag(ctx.args, "--color");
778
+ const positionStr = getFlag(ctx.args, "--position");
779
+ const position =
780
+ positionStr !== undefined
781
+ ? Number.parseInt(positionStr, 10)
782
+ : undefined;
783
+ if (position !== undefined && !Number.isFinite(position)) {
784
+ throw new CrmCliError(
785
+ `--position must be an integer, got "${positionStr}"`,
786
+ );
787
+ }
788
+ assertNoUnknownFlags(ctx.args, "crm fields enum-add");
789
+ out(
790
+ ctx,
791
+ await callMutation(ctx, api.fields.addEnumValue, {
792
+ objectName,
793
+ fieldName,
794
+ value,
795
+ color,
796
+ position,
797
+ }),
798
+ );
799
+ return;
800
+ }
801
+ case "enum-remove": {
802
+ const objectName = shift(ctx.args, "object name");
803
+ const fieldName = shift(ctx.args, "field name");
804
+ const value = shift(ctx.args, "enum value");
805
+ assertNoUnknownFlags(ctx.args, "crm fields enum-remove");
806
+ out(
807
+ ctx,
808
+ await callMutation(ctx, api.fields.removeEnumValue, {
809
+ objectName,
810
+ fieldName,
811
+ value,
812
+ }),
813
+ );
814
+ return;
815
+ }
816
+ case "enum-rename": {
817
+ const objectName = shift(ctx.args, "object name");
818
+ const fieldName = shift(ctx.args, "field name");
819
+ const oldValue = shift(ctx.args, "old enum value");
820
+ const newValue = shift(ctx.args, "new enum value");
821
+ assertNoUnknownFlags(ctx.args, "crm fields enum-rename");
822
+ out(
823
+ ctx,
824
+ await callMutation(ctx, api.fields.renameEnumValue, {
825
+ objectName,
826
+ fieldName,
827
+ oldValue,
828
+ newValue,
829
+ }),
830
+ );
831
+ return;
832
+ }
833
+ case "enum-set-color": {
834
+ const objectName = shift(ctx.args, "object name");
835
+ const fieldName = shift(ctx.args, "field name");
836
+ const value = shift(ctx.args, "enum value");
837
+ const color = shift(ctx.args, "hex color");
838
+ assertNoUnknownFlags(ctx.args, "crm fields enum-set-color");
839
+ out(
840
+ ctx,
841
+ await callMutation(ctx, api.fields.setEnumColor, {
842
+ objectName,
843
+ fieldName,
844
+ value,
845
+ color,
846
+ }),
847
+ );
848
+ return;
849
+ }
696
850
  default:
697
851
  throw new CrmCliError(`Unknown crm fields verb: ${verb ?? "<none>"}`);
698
852
  }
@@ -1704,6 +1858,21 @@ async function resolveEnrichmentTarget(
1704
1858
  };
1705
1859
  }
1706
1860
 
1861
+ /**
1862
+ * Build the gateway request body from a CRM entry the same way the
1863
+ * chat-turn `enrich_person` / `enrich_company` / `enrich_person_basic` /
1864
+ * `enrich_company_basic` tools do — pass every structured identifier the
1865
+ * row has (email, firstName, lastName, linkedinUrl, domain, …) and let the
1866
+ * shared gateway client (`enrichPerson`, `enrichCompany`,
1867
+ * `aviatoEnrichPerson`, `aviatoEnrichCompany`) validate + dispatch.
1868
+ *
1869
+ * The previous implementation collapsed the entry to a single
1870
+ * `--input-field` value and called `enrichPersonByIdentifier`, which
1871
+ * threw away the rest of the row and made FullEnrich fail with
1872
+ * `missing_people_identifier` whenever the input field was just an
1873
+ * Email (no first/last name reached the gateway, so the validator
1874
+ * rejected the request before any provider was called).
1875
+ */
1707
1876
  async function callEnrichmentGatewayForEntry(args: {
1708
1877
  target: EnrichmentTarget;
1709
1878
  entry: CrmEntryForEnrichment;
@@ -1711,64 +1880,175 @@ async function callEnrichmentGatewayForEntry(args: {
1711
1880
  provider?: string;
1712
1881
  gateway?: Pick<EnrichmentGatewayOptions, "apiKey" | "baseUrl">;
1713
1882
  }): Promise<Record<string, unknown>> {
1714
- const input = args.entry.fields[args.target.inputField.name];
1715
- if (!hasCellValue(input)) {
1883
+ const { entry, target } = args;
1884
+ // Sanity gate: the column the user picked as `--input-field` must be
1885
+ // populated. The body builder will pass every other available identifier
1886
+ // too, so the gateway has the best possible chance of resolving the row.
1887
+ if (!hasCellValue(entry.fields[target.inputField.name])) {
1716
1888
  throw new CrmCliError(
1717
- `Entry ${args.entry.id} has no value for input field "${args.target.inputField.name}"`,
1889
+ `Entry ${entry.id} has no value for input field "${target.inputField.name}"`,
1718
1890
  );
1719
1891
  }
1720
- const inputValue = String(input);
1721
- const isCompany = args.target.category === "company";
1892
+ const isCompany = target.category === "company";
1722
1893
  const resolvedProvider =
1723
1894
  args.provider ?? (isCompany ? "aviato" : "fullenrich");
1724
1895
 
1725
1896
  if (resolvedProvider === "aviato") {
1726
- // Guard: Aviato does not return phone or email.
1727
- const contactFields = args.target.requiredFields.filter(isContactField);
1897
+ // Aviato does not return phone or email — surface a clear error
1898
+ // before we waste a gateway round-trip.
1899
+ const contactFields = target.requiredFields.filter(isContactField);
1728
1900
  if (contactFields.length > 0) {
1729
1901
  throw new CrmCliError(
1730
1902
  `Aviato does not return phone or email; use --provider fullenrich for contact fields (${contactFields.join(", ")}).`,
1731
1903
  );
1732
1904
  }
1733
-
1734
1905
  if (isCompany) {
1735
- // inputValue is typically a domain; try to detect linkedin URLs
1736
- const isLinkedin = /linkedin\.com/i.test(inputValue);
1737
1906
  return aviatoEnrichCompany(
1738
- isLinkedin ? { linkedinUrl: inputValue } : { website: inputValue },
1907
+ buildAviatoCompanyBodyFromEntry(entry),
1739
1908
  args.gateway,
1740
1909
  );
1741
- } else {
1742
- // For people, inputValue may be a LinkedIn URL, email, or LinkedIn ID
1743
- const isLinkedin = /linkedin\.com/i.test(inputValue);
1744
- const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inputValue);
1745
- if (isLinkedin) {
1746
- return aviatoEnrichPerson({ linkedinUrl: inputValue }, args.gateway);
1747
- }
1748
- if (isEmail) {
1749
- return aviatoEnrichPerson({ email: inputValue }, args.gateway);
1750
- }
1751
- return aviatoEnrichPerson({ linkedinID: inputValue }, args.gateway);
1752
1910
  }
1911
+ return aviatoEnrichPerson(
1912
+ buildAviatoPersonBodyFromEntry(entry),
1913
+ args.gateway,
1914
+ );
1753
1915
  }
1754
1916
 
1755
- // fullenrich path (default for people, explicit override for companies)
1917
+ // FullEnrich path. Mirrors `enrichPersonStep` / `enrichCompanyStep` in
1918
+ // src/workflows/enrichment-steps.ts: build a structured body, hand it
1919
+ // to the same shared gateway client.
1756
1920
  if (isCompany) {
1757
1921
  return enrichCompany(
1758
- {
1759
- domain: inputValue,
1760
- requiredFields: args.target.requiredFields,
1761
- },
1922
+ buildCompanyBodyFromEntry(entry, target.requiredFields),
1762
1923
  args.gateway,
1763
1924
  );
1764
1925
  }
1765
- return enrichPersonByIdentifier(
1766
- inputValue,
1767
- args.target.requiredFields,
1926
+ return enrichPerson(
1927
+ buildPersonBodyFromEntry(entry, target.requiredFields),
1768
1928
  args.gateway,
1769
1929
  );
1770
1930
  }
1771
1931
 
1932
+ // ── Body builders ────────────────────────────────────────────────────────
1933
+ //
1934
+ // Pick whichever canonical column name the entry happens to use. Field
1935
+ // naming on CRM objects has historically drifted (`Name` vs `Full Name`,
1936
+ // `Email` vs `Email Address`, `Website` vs `Domain`), so each builder
1937
+ // accepts a small list of synonyms before giving up.
1938
+
1939
+ const PEOPLE_FULL_NAME_FIELDS = ["Name", "Full Name", "Person Name"] as const;
1940
+ const PEOPLE_FIRST_NAME_FIELDS = ["First Name", "Given Name"] as const;
1941
+ const PEOPLE_LAST_NAME_FIELDS = [
1942
+ "Last Name",
1943
+ "Family Name",
1944
+ "Surname",
1945
+ ] as const;
1946
+ const PEOPLE_EMAIL_FIELDS = [
1947
+ "Email",
1948
+ "Email Address",
1949
+ "Work Email",
1950
+ "Personal Email",
1951
+ ] as const;
1952
+ const PEOPLE_LINKEDIN_FIELDS = [
1953
+ "LinkedIn URL",
1954
+ "LinkedIn",
1955
+ "Linkedin URL",
1956
+ ] as const;
1957
+ const PEOPLE_DOMAIN_FIELDS = ["Domain", "Company Domain"] as const;
1958
+
1959
+ const COMPANY_WEBSITE_FIELDS = ["Website", "Domain", "URL", "Site"] as const;
1960
+ const COMPANY_LINKEDIN_FIELDS = [
1961
+ "LinkedIn URL",
1962
+ "LinkedIn",
1963
+ "Linkedin URL",
1964
+ ] as const;
1965
+ const COMPANY_NAME_FIELDS = [
1966
+ "Company Name",
1967
+ "Name",
1968
+ "Organization",
1969
+ "Organization Name",
1970
+ ] as const;
1971
+
1972
+ function readEntryField(
1973
+ entry: CrmEntryForEnrichment,
1974
+ candidates: readonly string[],
1975
+ ): string | undefined {
1976
+ for (const name of candidates) {
1977
+ const value = entry.fields[name];
1978
+ if (typeof value === "string") {
1979
+ const trimmed = value.trim();
1980
+ if (trimmed.length > 0) return trimmed;
1981
+ }
1982
+ }
1983
+ return undefined;
1984
+ }
1985
+
1986
+ /** Split "Mendy Edri" → { firstName: "Mendy", lastName: "Edri" }. */
1987
+ export function splitFullName(fullName: string): {
1988
+ firstName?: string;
1989
+ lastName?: string;
1990
+ } {
1991
+ const trimmed = fullName.trim();
1992
+ if (!trimmed) return {};
1993
+ const parts = trimmed.split(/\s+/);
1994
+ if (parts.length === 1) return { firstName: parts[0] };
1995
+ return { firstName: parts[0], lastName: parts.slice(1).join(" ") };
1996
+ }
1997
+
1998
+ export function buildPersonBodyFromEntry(
1999
+ entry: CrmEntryForEnrichment,
2000
+ requiredFields: string[] | undefined,
2001
+ ): EnrichPersonBody {
2002
+ let firstName = readEntryField(entry, PEOPLE_FIRST_NAME_FIELDS);
2003
+ let lastName = readEntryField(entry, PEOPLE_LAST_NAME_FIELDS);
2004
+ if (!firstName || !lastName) {
2005
+ const fullName = readEntryField(entry, PEOPLE_FULL_NAME_FIELDS);
2006
+ if (fullName) {
2007
+ const split = splitFullName(fullName);
2008
+ firstName ??= split.firstName;
2009
+ lastName ??= split.lastName;
2010
+ }
2011
+ }
2012
+ return {
2013
+ email: readEntryField(entry, PEOPLE_EMAIL_FIELDS),
2014
+ linkedinUrl: readEntryField(entry, PEOPLE_LINKEDIN_FIELDS),
2015
+ firstName,
2016
+ lastName,
2017
+ domain: readEntryField(entry, PEOPLE_DOMAIN_FIELDS),
2018
+ requiredFields,
2019
+ };
2020
+ }
2021
+
2022
+ export function buildCompanyBodyFromEntry(
2023
+ entry: CrmEntryForEnrichment,
2024
+ requiredFields: string[] | undefined,
2025
+ ): EnrichCompanyBody {
2026
+ return {
2027
+ domain: readEntryField(entry, COMPANY_WEBSITE_FIELDS),
2028
+ companyName: readEntryField(entry, COMPANY_NAME_FIELDS),
2029
+ linkedinUrl: readEntryField(entry, COMPANY_LINKEDIN_FIELDS),
2030
+ requiredFields,
2031
+ };
2032
+ }
2033
+
2034
+ export function buildAviatoPersonBodyFromEntry(
2035
+ entry: CrmEntryForEnrichment,
2036
+ ): AviatoEnrichPersonBody {
2037
+ return {
2038
+ linkedinUrl: readEntryField(entry, PEOPLE_LINKEDIN_FIELDS),
2039
+ email: readEntryField(entry, PEOPLE_EMAIL_FIELDS),
2040
+ };
2041
+ }
2042
+
2043
+ export function buildAviatoCompanyBodyFromEntry(
2044
+ entry: CrmEntryForEnrichment,
2045
+ ): AviatoEnrichCompanyBody {
2046
+ return {
2047
+ website: readEntryField(entry, COMPANY_WEBSITE_FIELDS),
2048
+ linkedinUrl: readEntryField(entry, COMPANY_LINKEDIN_FIELDS),
2049
+ };
2050
+ }
2051
+
1772
2052
  async function enrichOneEntry(
1773
2053
  ctx: CrmCliContext,
1774
2054
  args: Omit<CellEnrichmentArgs, "entryId"> & {
@@ -1776,6 +2056,7 @@ async function enrichOneEntry(
1776
2056
  entry: CrmEntryForEnrichment;
1777
2057
  },
1778
2058
  ): Promise<Record<string, unknown>> {
2059
+ let value: unknown;
1779
2060
  try {
1780
2061
  const payload = await callEnrichmentGatewayForEntry({
1781
2062
  target: args.target,
@@ -1783,32 +2064,7 @@ async function enrichOneEntry(
1783
2064
  provider: args.provider,
1784
2065
  gateway: ctx.enrichmentGateway,
1785
2066
  });
1786
- const value = extractEnrichmentValue(payload, args.target.column);
1787
- if (value === null) {
1788
- return {
1789
- entryId: args.entry.id,
1790
- ok: false,
1791
- reason: "value_not_found",
1792
- };
1793
- }
1794
- const writeResult = (await callMutation(
1795
- ctx,
1796
- api.enrich.applyCellEnrichmentResult,
1797
- {
1798
- objectName: args.objectName,
1799
- entryId: args.entry.id,
1800
- fieldName: args.fieldName,
1801
- value,
1802
- provider: args.provider ?? "gateway",
1803
- overwrite: args.overwrite,
1804
- },
1805
- )) as Record<string, unknown>;
1806
- return {
1807
- entryId: args.entry.id,
1808
- ok: writeResult.ok === true,
1809
- value,
1810
- ...writeResult,
1811
- };
2067
+ value = extractEnrichmentValue(payload, args.target.column);
1812
2068
  } catch (error) {
1813
2069
  if (
1814
2070
  error instanceof EnrichmentGatewayError ||
@@ -1826,6 +2082,31 @@ async function enrichOneEntry(
1826
2082
  }
1827
2083
  throw error;
1828
2084
  }
2085
+ if (value === null) {
2086
+ return {
2087
+ entryId: args.entry.id,
2088
+ ok: false,
2089
+ reason: "value_not_found",
2090
+ };
2091
+ }
2092
+ const writeResult = (await callMutation(
2093
+ ctx,
2094
+ api.enrich.applyCellEnrichmentResult,
2095
+ {
2096
+ objectName: args.objectName,
2097
+ entryId: args.entry.id,
2098
+ fieldName: args.fieldName,
2099
+ value,
2100
+ provider: args.provider ?? "gateway",
2101
+ overwrite: args.overwrite,
2102
+ },
2103
+ )) as Record<string, unknown>;
2104
+ return {
2105
+ entryId: args.entry.id,
2106
+ ok: writeResult.ok === true,
2107
+ value,
2108
+ ...writeResult,
2109
+ };
1829
2110
  }
1830
2111
 
1831
2112
  async function enrichCellThroughGateway(
@@ -2148,6 +2429,28 @@ Fields (columns):
2148
2429
  dench crm fields delete <object> <field>
2149
2430
  dench crm fields reorder <object> <field1> <field2> <field3> ...
2150
2431
 
2432
+ Enum option management (on existing enum fields):
2433
+ dench crm fields enum-reorder <object> <field> <value1> <value2> ...
2434
+ Canonical way to set the order of kanban columns / table picker options.
2435
+ The trailing positional values must be a permutation of the field's
2436
+ current enumValues; additions/removals are rejected (use enum-add /
2437
+ enum-remove first). Also keeps any matching crmStatuses rows in sync
2438
+ so the kanban (which prefers statuses over enumValues) reflects the
2439
+ new order.
2440
+ dench crm fields enum-add <object> <field> <value> [--color HEX] [--position N]
2441
+ Appends <value> to the field's enumValues. --color is a hex like
2442
+ "#22c55e"; --position is a 0-based insertion index (defaults to append).
2443
+ Idempotent: re-adding an existing value only updates its color when given.
2444
+ dench crm fields enum-remove <object> <field> <value>
2445
+ Removes the value from the option list. Historical entry rows that held
2446
+ <value> keep the literal string (the badge falls back to the default
2447
+ tint), matching Notion's "remove option, keep history" semantics.
2448
+ dench crm fields enum-rename <object> <field> <oldValue> <newValue>
2449
+ Renames the option AND rewrites every entry that referenced the old
2450
+ value (bounded to 1000 rows per call) AND renames matching crmStatuses.
2451
+ dench crm fields enum-set-color <object> <field> <value> <hex>
2452
+ Update the color (e.g. "#3b82f6") for an existing enum option.
2453
+
2151
2454
  Entries (rows):
2152
2455
  dench crm entries list <object> [--limit N] [--with-meta] [--json]
2153
2456
  dench crm entries get <object> <entryId>
package/dench.ts CHANGED
@@ -376,6 +376,30 @@ function parseCommaOption(name: string) {
376
376
  );
377
377
  }
378
378
 
379
+ function parseMemoryVisibilityOption() {
380
+ const scope = option("--scope");
381
+ const visibility = option("--visibility");
382
+ if (scope && visibility && scope !== visibility) {
383
+ throw new CliError(
384
+ "--scope and --visibility must match when both are set",
385
+ {
386
+ code: "memory_scope_conflict",
387
+ nextActions: ["Use only --scope user or --scope org."],
388
+ },
389
+ );
390
+ }
391
+ const value = scope ?? visibility ?? "user";
392
+ if (value !== "user" && value !== "org") {
393
+ throw new CliError("--scope must be user or org", {
394
+ code: "invalid_memory_scope",
395
+ nextActions: [
396
+ "Use --scope user for private user memory or --scope org for shared organization memory.",
397
+ ],
398
+ });
399
+ }
400
+ return value;
401
+ }
402
+
379
403
  function logHuman(message: string) {
380
404
  if (!json) {
381
405
  console.error(message);
@@ -398,7 +422,7 @@ Usage:
398
422
  dench context [--json]
399
423
  dench status [--mine|--self] [--json]
400
424
  dench memory search "query" [--limit 8] [--json]
401
- dench memory save <key> "text" [--kind fact|decision|preference|goal|tool_note|other] [--tags a,b] [--sensitivity normal|broad|sensitive] [--json]
425
+ dench memory save <key> "text" [--kind fact|decision|preference|goal|tool_note|other] [--tags a,b] [--sensitivity normal|broad|sensitive] [--scope user|org] [--json]
402
426
  dench artifacts [--limit 25] [--json]
403
427
  dench suggested-work [--json]
404
428
  dench approval request "message" [--json]
@@ -610,10 +634,11 @@ function memoryHelp() {
610
634
 
611
635
  Usage:
612
636
  dench memory search "query" [--limit 8] [--json]
613
- dench memory save <key> "text" [--kind fact|decision|preference|goal|tool_note|other] [--tags a,b] [--sensitivity normal|broad|sensitive] [--json]
637
+ dench memory save <key> "text" [--kind fact|decision|preference|goal|tool_note|other] [--tags a,b] [--sensitivity normal|broad|sensitive] [--scope user|org] [--json]
614
638
 
615
639
  Use memory only for stable facts, decisions, preferences, recurring goals, and
616
- tool notes. Do not store secrets or scratch notes.
640
+ tool notes. Do not store secrets or scratch notes. Saved memories are scoped to
641
+ the signed-in user by default; pass --scope org to share with the organization.
617
642
  `);
618
643
  }
619
644
 
@@ -2419,6 +2444,7 @@ function requireAuthenticatedRuntime(
2419
2444
  type GatewayCommandAuth = {
2420
2445
  bearerToken: string;
2421
2446
  gatewayBaseUrl?: string;
2447
+ gatewayHeaders?: Record<string, string>;
2422
2448
  };
2423
2449
 
2424
2450
  function gatewayBaseUrlForCli(value: unknown): string | undefined {
@@ -2515,6 +2541,12 @@ async function resolveGatewayCommandAuth(
2515
2541
  return {
2516
2542
  bearerToken: gatewayKey,
2517
2543
  gatewayBaseUrl: gatewayBaseUrlForCli(body.gatewayBaseUrl),
2544
+ gatewayHeaders:
2545
+ body.gatewayHeaders &&
2546
+ typeof body.gatewayHeaders === "object" &&
2547
+ !Array.isArray(body.gatewayHeaders)
2548
+ ? (body.gatewayHeaders as Record<string, string>)
2549
+ : undefined,
2518
2550
  };
2519
2551
  }
2520
2552
 
@@ -2622,6 +2654,7 @@ async function connectedAppsContext(runtime: Runtime) {
2622
2654
  return fetchConnectedAppsSummary({
2623
2655
  bearerToken: gatewayAuth.bearerToken,
2624
2656
  gatewayBaseUrl: gatewayAuth.gatewayBaseUrl,
2657
+ gatewayHeaders: gatewayAuth.gatewayHeaders,
2625
2658
  });
2626
2659
  } catch (error) {
2627
2660
  return {
@@ -3822,6 +3855,7 @@ async function main() {
3822
3855
  jsonOutput: json,
3823
3856
  bearerToken: gatewayAuth.bearerToken,
3824
3857
  gatewayBaseUrl: gatewayAuth.gatewayBaseUrl,
3858
+ gatewayHeaders: gatewayAuth.gatewayHeaders,
3825
3859
  });
3826
3860
  return;
3827
3861
  }
@@ -3841,6 +3875,7 @@ async function main() {
3841
3875
  jsonOutput: json,
3842
3876
  bearerToken: gatewayAuth.bearerToken,
3843
3877
  gatewayBaseUrl: gatewayAuth.gatewayBaseUrl,
3878
+ gatewayHeaders: gatewayAuth.gatewayHeaders,
3844
3879
  });
3845
3880
  return;
3846
3881
  }
@@ -4135,6 +4170,7 @@ async function main() {
4135
4170
  text,
4136
4171
  tags: parseCommaOption("--tags"),
4137
4172
  sensitivity: (option("--sensitivity") ?? "normal") as never,
4173
+ visibility: parseMemoryVisibilityOption() as never,
4138
4174
  },
4139
4175
  ),
4140
4176
  );
@@ -1280,6 +1280,28 @@ async function buildGatewayError(
1280
1280
  code: code ?? "not_found",
1281
1281
  });
1282
1282
  }
1283
+ if (
1284
+ response.status === 401 ||
1285
+ code === "invalid_api_key" ||
1286
+ code === "unauthorized"
1287
+ ) {
1288
+ return new EnrichmentGatewayError(
1289
+ upstreamMessage ?? "Unauthorized gateway request",
1290
+ { status: response.status, code: "unauthorized" },
1291
+ );
1292
+ }
1293
+ if (response.status === 403 || code === "forbidden") {
1294
+ return new EnrichmentGatewayError(
1295
+ upstreamMessage ?? "Forbidden gateway request",
1296
+ { status: response.status, code: "forbidden" },
1297
+ );
1298
+ }
1299
+ if (code === "aviato_unauthorized") {
1300
+ return new EnrichmentGatewayError(
1301
+ upstreamMessage ?? "Aviato provider unauthorized",
1302
+ { status: response.status || 401, code: "aviato_unauthorized" },
1303
+ );
1304
+ }
1283
1305
  if (response.status === 503 || code === "provider_unavailable") {
1284
1306
  return new EnrichmentGatewayError("Gateway providers unavailable", {
1285
1307
  status: response.status,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.0.4",
3
+ "version": "2.1.1",
4
4
  "description": "Dench agent workspace CLI. v2 unifies auth behind `dench signin`; the legacy `dench login` / `dench onboard` / `dench setup` / `dench what-can-i-do` / `dench register` commands now error with a redirect.",
5
5
  "type": "module",
6
6
  "bin": {
package/tools.ts CHANGED
@@ -50,11 +50,12 @@ export type ToolCliContext = {
50
50
  jsonOutput: boolean;
51
51
  bearerToken?: string;
52
52
  gatewayBaseUrl?: string;
53
+ gatewayHeaders?: Record<string, string>;
53
54
  };
54
55
 
55
56
  type GatewayAuthContext = Pick<
56
57
  ToolCliContext,
57
- "bearerToken" | "gatewayBaseUrl"
58
+ "bearerToken" | "gatewayBaseUrl" | "gatewayHeaders"
58
59
  >;
59
60
 
60
61
  export async function runToolCommand(ctx: ToolCliContext): Promise<void> {
@@ -453,6 +454,7 @@ async function callGateway(
453
454
  headers: {
454
455
  "content-type": "application/json",
455
456
  authorization: `Bearer ${bearerToken}`,
457
+ ...(ctx.gatewayHeaders ?? {}),
456
458
  },
457
459
  };
458
460
  if (body !== undefined) init.body = JSON.stringify(body);