@hireobi/cli 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,24 +1,24 @@
1
1
  # Obi CLI
2
2
 
3
- Obi is an AI Search Employee. This package installs the `obi` CLI and the Obi Skill, which runs inside the assistant you already use: Claude Code, Cursor or Codex. The Skill does the work in the chat: public business research saved through a versioned work boundary, buyer questions, and server-measured engine answers kept apart from your assistant's own reasoning. Create your account at [agent.hireobi.com](https://agent.hireobi.com); learn more at [hireobi.com](https://hireobi.com).
3
+ Obi is an AI Search Employee. This package installs the `obi` CLI and the Obi Skill, which runs inside the assistant you already use: Claude Code, Cursor, Codex or OpenCode. The Skill does the work in the chat: public business research saved through a versioned work boundary, buyer questions, and server-measured engine answers kept apart from your assistant's own reasoning. Create your account at [agent.hireobi.com](https://agent.hireobi.com); learn more at [hireobi.com](https://hireobi.com).
4
4
 
5
5
  ## Install
6
6
 
7
- Node.js 22 or later is required:
7
+ Node.js 22 or later is required. One line, in a terminal or pasted to your coding agent:
8
8
 
9
9
  ```bash
10
- npm install --global @hireobi/cli
11
- obi --version
12
- obi skill install
10
+ npm i -g @hireobi/cli && obi
13
11
  ```
14
12
 
15
- `obi skill install` registers exactly two personal Skill links, one for each supported local directory, and refuses a conflicting file or broken link, leaving the other Skill intact. `obi skill path` locates the bundled Skill. It also removes the Skill links an earlier alpha of this package registered under its former name, so an assistant lists one Skill. A cloud harness needs its own supported installation path.
13
+ Windows PowerShell 5.1 has no `&&`: run `npm i -g @hireobi/cli`, then `obi`.
16
14
 
17
- ## Use
15
+ The first `obi` registers the Obi Skill for Claude Code, Codex, Cursor and OpenCode, and every later command keeps it registered: two personal links, `~/.claude/skills/obi` and `~/.agents/skills/obi`. A conflicting file or link is never replaced: the command still runs, and bare `obi` says what is in the way and how to fix it. `obi skill install` registers it again after a fix and removes the links an earlier alpha of this package made under its former name; `obi skill path` locates the bundled Skill. `OBI_SKILL_LINKS=off` leaves the links to `obi skill install`. A cloud harness needs its own supported installation path.
16
+
17
+ Run by a coding agent, bare `obi` prints where the Skill's guide is and the next command. In an interactive terminal it shows the Obi mascot and a short welcome, then exits: the conversation happens in the assistant. The greeting is English or French; `OBI_LANGUAGE` overrides `LC_ALL`, `LC_MESSAGES`, then `LANG`.
18
18
 
19
- Open a new assistant chat. In [Claude Code](https://code.claude.com/docs/en/skills) or [Cursor](https://cursor.com/docs/skills), type `/obi`. In Codex CLI, type `$obi` or select Obi through `/skills`; in the desktop app, use the [skills picker](https://learn.chatgpt.com/docs/build-skills). The Skill asks which website to work on and follows the conversation language.
19
+ ## Use
20
20
 
21
- Running bare `obi` in an interactive terminal shows the Obi mascot and a short welcome, then exits: the conversation happens in the assistant. The greeting is English or French; `OBI_LANGUAGE` overrides `LC_ALL`, `LC_MESSAGES`, then `LANG`.
21
+ In [Claude Code](https://code.claude.com/docs/en/skills) or [Cursor](https://cursor.com/docs/skills), type `/obi`; in Claude Code, `/reload-skills` lists it when the session started before the install. In Codex CLI, type `$obi` or select Obi through `/skills`; in the desktop app, use the [skills picker](https://learn.chatgpt.com/docs/build-skills). In [OpenCode](https://opencode.ai/docs/skills/), type `@obi`. The Skill asks which website to work on and follows the conversation language.
22
22
 
23
23
  Run `obi --help` for the complete command grammar and the starting steps. `--json` returns one validated envelope per invocation. After a timeout or a conflict, read the current state before retrying a write; a submission may be resent with the same file and replay key. Credentials stay in the system credential store or an explicitly selected owner-only file, and the CLI never prints tokens. The Skill's references carry the result contracts; `obi skill path` shows where they are installed.
24
24
 
@@ -26,7 +26,7 @@ An authorized operator can start an isolated public-company evaluation case with
26
26
 
27
27
  ## Support
28
28
 
29
- Write to [support@hireobi.com](mailto:support@hireobi.com) or open an issue at [github.com/hireobi/cli](https://github.com/hireobi/cli/issues).
29
+ Run `obi support "<what happened>"`, write to [support@hireobi.com](mailto:support@hireobi.com) or open an issue at [github.com/hireobi/cli](https://github.com/hireobi/cli/issues).
30
30
 
31
31
  ## Repository development
32
32
 
package/dist/obi.js CHANGED
@@ -14618,6 +14618,7 @@ var optionDefinitions = {
14618
14618
  "field-path": { type: "string" },
14619
14619
  "segment-name": { type: "string" },
14620
14620
  text: { type: "string" },
14621
+ tag: { type: "string" },
14621
14622
  company: { type: "string" },
14622
14623
  "company-unknown": { type: "boolean" },
14623
14624
  product: { type: "string" },
@@ -14632,7 +14633,7 @@ var optionDefinitions = {
14632
14633
  help: { type: "boolean" },
14633
14634
  version: { type: "boolean" }
14634
14635
  };
14635
- var namedCommands = ["business", "capabilities", "credentials", "doctor", "eval", "harness", "login", "logout", "memory", "questions", "readiness", "research", "skill", "status", "strategies"];
14636
+ var namedCommands = ["business", "capabilities", "credentials", "doctor", "eval", "harness", "login", "logout", "memory", "questions", "readiness", "research", "skill", "status", "strategies", "support"];
14636
14637
  var memoryKinds = ["preference", "constraint", "decision"];
14637
14638
  var credentialsActions = ["list", "revoke"];
14638
14639
  var emailSchema = external_exports.email().max(254);
@@ -14651,6 +14652,8 @@ var CliError = class extends Error {
14651
14652
  command;
14652
14653
  authorization;
14653
14654
  account;
14655
+ /** The message `obi support` could not send, with the block to paste. */
14656
+ support;
14654
14657
  constructor(options) {
14655
14658
  super(options.message);
14656
14659
  this.name = "CliError";
@@ -14662,6 +14665,7 @@ var CliError = class extends Error {
14662
14665
  this.command = options.command;
14663
14666
  this.authorization = options.authorization;
14664
14667
  this.account = options.account;
14668
+ this.support = options.support;
14665
14669
  }
14666
14670
  };
14667
14671
  function usageError(message = "Invalid command usage.", options = {}) {
@@ -15174,6 +15178,52 @@ function parseMemoryArguments(positionals, values, context, common2) {
15174
15178
  throw invalid3();
15175
15179
  }
15176
15180
 
15181
+ // src/support-contract.ts
15182
+ var SUPPORT_EMAIL = "support@hireobi.com";
15183
+ var SUPPORT_TAGS = ["market-request", "scope-request"];
15184
+ var lastErrorSchema = external_exports.strictObject({
15185
+ code: external_exports.string().regex(/^[A-Z][A-Z0-9_]{0,79}$/),
15186
+ command: external_exports.string().regex(/^[a-z][a-z-]{0,39}$/),
15187
+ at: external_exports.iso.datetime({ offset: true })
15188
+ });
15189
+ var SupportMessageSchema = external_exports.strictObject({
15190
+ requestId: external_exports.string().regex(/^[0-9a-f]{32}$/),
15191
+ message: external_exports.string().trim().min(1).max(2e3),
15192
+ tag: external_exports.enum(SUPPORT_TAGS).nullable(),
15193
+ diagnostic: external_exports.strictObject({
15194
+ cliVersion: external_exports.string().min(1).max(40),
15195
+ node: external_exports.string().min(1).max(40),
15196
+ os: external_exports.string().min(1).max(120),
15197
+ lastError: lastErrorSchema.nullable()
15198
+ })
15199
+ });
15200
+ var SupportMessageResponseSchema = external_exports.object({
15201
+ schemaVersion: external_exports.literal(1),
15202
+ data: external_exports.object({ received: external_exports.literal(true) })
15203
+ });
15204
+ var supportDataSchema = external_exports.object({
15205
+ delivery: external_exports.enum(["sent", "not_sent"]),
15206
+ /** Why nothing was sent: the error code met, null once sent. */
15207
+ reason: external_exports.string().nullable(),
15208
+ supportEmail: external_exports.literal(SUPPORT_EMAIL),
15209
+ mailto: external_exports.string(),
15210
+ tag: external_exports.enum(SUPPORT_TAGS).nullable(),
15211
+ account: external_exports.string().nullable(),
15212
+ origin: external_exports.string().min(1),
15213
+ diagnosticText: external_exports.string()
15214
+ });
15215
+
15216
+ // src/support-arguments.ts
15217
+ function parseSupportArguments(positionals, values, context, common2) {
15218
+ const allowed = /* @__PURE__ */ new Set(["json", "timeout-ms", "tag"]);
15219
+ const message = positionals[1]?.trim() ?? "";
15220
+ const tag = values.tag;
15221
+ if (Object.entries(values).some(([key, value]) => value !== void 0 && value !== false && !allowed.has(key)) || positionals.length !== 2 || message.length === 0 || message.length > 2e3 || tag !== void 0 && !SUPPORT_TAGS.includes(tag)) {
15222
+ throw usageError(`Use support "<message of at most 2000 characters>" [--tag ${SUPPORT_TAGS.join("|")}].`, context);
15223
+ }
15224
+ return { command: "support", supportMessage: message, ...tag === void 0 ? {} : { supportTag: tag }, ...common2 };
15225
+ }
15226
+
15177
15227
  // src/research-arguments.ts
15178
15228
  function parseResearchScope(values, context) {
15179
15229
  const locale = values.locale;
@@ -15576,7 +15626,7 @@ function commandMode(command, testServer) {
15576
15626
  if (testServer) {
15577
15627
  return "test";
15578
15628
  }
15579
- return command === "login" || command === "logout" || command === "status" || command === "credentials" || command === "eval" || command === "harness" || command === "business" || command === "questions" || command === "strategies" || command === "readiness" || command === "memory" ? "product" : "local";
15629
+ return command === "login" || command === "logout" || command === "status" || command === "credentials" || command === "eval" || command === "harness" || command === "business" || command === "questions" || command === "strategies" || command === "readiness" || command === "memory" || command === "support" ? "product" : "local";
15580
15630
  }
15581
15631
  function errorContext(argv) {
15582
15632
  const usesTestServer = argv.some(
@@ -15762,6 +15812,8 @@ function parseCommandArguments(argv) {
15762
15812
  throw usageError("Use research crawl <domain> --checkpoint <file> --output <file> [--batch-size <1-100>] [--locale <tag>] [--pages priority|all] [--max-pages <n|none>] [--retry-failed|--refresh], research capture --input <selected-page-file> --checkpoint <file> --output <corpus-file>, research readiness --input <file> --output <file>, or research video <url> --output <directory> [--frames <0-30>].", context);
15763
15813
  }
15764
15814
  if (positionals[0] === "memory") return parseMemoryArguments(positionals, values, context, { evaluationCaseId, json: json2, timeoutMs });
15815
+ if (positionals[0] === "support") return parseSupportArguments(positionals, values, context, { json: json2, timeoutMs });
15816
+ if (values.tag !== void 0) throw usageError("--tag is only valid with obi support.", context);
15765
15817
  if (stage !== void 0 || input !== void 0 || output !== void 0 || checkpoint !== void 0 || batchSize !== void 0 || retryFailed || refresh || scopeFlagsUsed || values.frames !== void 0 || free || evidenceOptionsUsed) {
15766
15818
  throw usageError("--stage and --input are only valid with obi harness.", context);
15767
15819
  }
@@ -15856,9 +15908,6 @@ function parseCommandArguments(argv) {
15856
15908
  }
15857
15909
  if (command === "doctor") {
15858
15910
  if (evaluationCaseId !== void 0) throw usageError("Doctor cannot use --eval-case.", context);
15859
- if (timeoutValue !== void 0 && testServer === void 0) {
15860
- throw usageError("A timeout is only valid with a test server.", context);
15861
- }
15862
15911
  return { command, json: json2, testServer, timeoutMs };
15863
15912
  }
15864
15913
  if (command === "status") {
@@ -16587,6 +16636,34 @@ var BusinessItemReviewRequestSchema = external_exports.discriminatedUnion("decis
16587
16636
  })
16588
16637
  ]);
16589
16638
 
16639
+ // ../shared/lib/geo/markets.ts
16640
+ var SUPPORTED_MARKETS = [
16641
+ { iso: "US", name: "United States" },
16642
+ { iso: "CA", name: "Canada" },
16643
+ { iso: "GB", name: "United Kingdom" },
16644
+ { iso: "FR", name: "France" },
16645
+ { iso: "DE", name: "Germany" },
16646
+ { iso: "ES", name: "Spain" },
16647
+ { iso: "IT", name: "Italy" },
16648
+ { iso: "NL", name: "Netherlands" },
16649
+ { iso: "BE", name: "Belgium" },
16650
+ { iso: "CH", name: "Switzerland" },
16651
+ { iso: "AU", name: "Australia" },
16652
+ { iso: "SA", name: "Saudi Arabia" },
16653
+ { iso: "AE", name: "UAE" },
16654
+ { iso: "QA", name: "Qatar" },
16655
+ { iso: "KW", name: "Kuwait" }
16656
+ ];
16657
+ var BY_KEY = new Map(
16658
+ SUPPORTED_MARKETS.flatMap((market) => [
16659
+ [market.iso.toLowerCase(), market],
16660
+ [market.name.toLowerCase(), market]
16661
+ ])
16662
+ );
16663
+ var SUPPORTED_MARKET_NAMES = SUPPORTED_MARKETS.map(
16664
+ (market) => market.name
16665
+ );
16666
+
16590
16667
  // src/product-api-failures.ts
16591
16668
  function transportError(code, message, exitCode, retryable) {
16592
16669
  return new CliError({
@@ -16594,7 +16671,7 @@ function transportError(code, message, exitCode, retryable) {
16594
16671
  message,
16595
16672
  exitCode,
16596
16673
  retryable,
16597
- nextAction: exitCode === 6 ? "Check the network connection and try again." : "Update the Obi CLI, then try again.",
16674
+ nextAction: exitCode === 6 ? `Check the network connection and try again. If it keeps failing, run obi support "<what happened>" or write to ${SUPPORT_EMAIL} with the command you ran and the error code ${code}.` : "Update the Obi CLI, then try again.",
16598
16675
  mode: "product"
16599
16676
  });
16600
16677
  }
@@ -16624,6 +16701,14 @@ function failureError(failure2, evaluationCaseId) {
16624
16701
  mode: "product",
16625
16702
  nextAction: `Run obi business show${scope} --json and read the current work before preparing a new result.`
16626
16703
  });
16704
+ if (failure2.code === "MARKET_UNSUPPORTED") return new CliError({
16705
+ code: failure2.code,
16706
+ message: "Obi does not measure this market yet.",
16707
+ exitCode: 5,
16708
+ retryable: false,
16709
+ mode: "product",
16710
+ nextAction: `Obi measures in: ${SUPPORTED_MARKET_NAMES.join(", ")}. Choose one of them with the customer, or record the request with obi support "<market and domain>" --tag market-request.`
16711
+ });
16627
16712
  if (failure2.code === "RATE_LIMITED") {
16628
16713
  const wait = failure2.retryAfterSeconds === null ? "a moment" : `${failure2.retryAfterSeconds} seconds`;
16629
16714
  return new CliError({
@@ -17213,7 +17298,7 @@ var originMarker = {
17213
17298
  };
17214
17299
  var helpDataSchema = external_exports.object({
17215
17300
  usage: external_exports.string(),
17216
- commands: external_exports.array(external_exports.enum(["business", "capabilities", "credentials", "doctor", "eval", "harness", "login", "logout", "memory", "questions", "readiness", "research", "skill", "status", "strategies"]))
17301
+ commands: external_exports.array(external_exports.enum(["business", "capabilities", "credentials", "doctor", "eval", "harness", "login", "logout", "memory", "questions", "readiness", "research", "skill", "status", "strategies", "support"]))
17217
17302
  });
17218
17303
  var versionDataSchema = external_exports.object({ version: external_exports.string() });
17219
17304
  var skillDataSchema = external_exports.discriminatedUnion("action", [
@@ -17223,6 +17308,13 @@ var skillDataSchema = external_exports.discriminatedUnion("action", [
17223
17308
  var capabilitiesDataSchema = external_exports.object({
17224
17309
  expertise: external_exports.object({ manifestVersion: external_exports.string(), competencies: external_exports.array(external_exports.object({ id: external_exports.string(), reference: external_exports.string(), sha256: external_exports.string(), reviewedOn: external_exports.string(), sources: external_exports.array(external_exports.string()) })) }).optional(),
17225
17310
  version: external_exports.string(),
17311
+ measurementMarkets: external_exports.array(external_exports.object({ iso: external_exports.string().length(2), name: external_exports.string().min(1) })),
17312
+ catalog: external_exports.object({ version: external_exports.string(), currency: external_exports.literal("usd"), monthlyCents: external_exports.object({
17313
+ corePack: external_exports.number().int().positive(),
17314
+ claudePerPack: external_exports.number().int().positive(),
17315
+ perplexityPerPack: external_exports.number().int().positive()
17316
+ }) }),
17317
+ support: external_exports.object({ email: external_exports.literal(SUPPORT_EMAIL) }),
17226
17318
  realLogin: external_exports.literal("available"),
17227
17319
  paidExecution: external_exports.literal("server_authorized"),
17228
17320
  commands: external_exports.object({
@@ -17240,7 +17332,8 @@ var capabilitiesDataSchema = external_exports.object({
17240
17332
  memory: external_exports.object({ actions: external_exports.array(external_exports.string()), options: external_exports.array(external_exports.string()) }).optional(),
17241
17333
  status: external_exports.object({ options: external_exports.array(external_exports.string()) }),
17242
17334
  readiness: external_exports.object({ options: external_exports.array(external_exports.string()) }),
17243
- strategies: external_exports.object({ options: external_exports.array(external_exports.string()) })
17335
+ strategies: external_exports.object({ options: external_exports.array(external_exports.string()) }),
17336
+ support: external_exports.object({ tags: external_exports.array(external_exports.enum(SUPPORT_TAGS)), options: external_exports.array(external_exports.string()) })
17244
17337
  })
17245
17338
  });
17246
17339
  var localDoctorDataSchema = external_exports.object({
@@ -17249,6 +17342,7 @@ var localDoctorDataSchema = external_exports.object({
17249
17342
  origin: external_exports.string(),
17250
17343
  credentialStorage: external_exports.union([backendKindSchema, external_exports.literal("none")]),
17251
17344
  videoTools: external_exports.object({ ytDlp: external_exports.boolean(), ffmpeg: external_exports.boolean() }),
17345
+ server: external_exports.enum(["reachable", "degraded", "unreachable"]),
17252
17346
  diagnostic: external_exports.literal("completed")
17253
17347
  });
17254
17348
  var loginDataSchema = external_exports.object({
@@ -17545,6 +17639,7 @@ var successfulResultSchema = external_exports.union([
17545
17639
  external_exports.object({ ...envelope2, command: external_exports.literal("harness"), mode: external_exports.literal("product"), data: harnessCoverageDataSchema }),
17546
17640
  ...harnessEvidenceDataSchema.map((schema) => external_exports.object({ ...envelope2, command: external_exports.literal("harness"), mode: external_exports.literal("product"), data: schema })),
17547
17641
  external_exports.object({ ...envelope2, command: external_exports.literal("harness"), mode: external_exports.literal("product"), data: harnessEvidenceExportDataSchema }),
17642
+ external_exports.object({ ...envelope2, command: external_exports.literal("support"), mode: external_exports.literal("product"), data: supportDataSchema }),
17548
17643
  external_exports.object({ ...envelope2, command: external_exports.literal("memory"), mode: external_exports.literal("product"), data: memoryListDataSchema }),
17549
17644
  external_exports.object({ ...envelope2, command: external_exports.literal("memory"), mode: external_exports.literal("product"), data: memoryChangeDataSchema }),
17550
17645
  external_exports.object({
@@ -17595,7 +17690,8 @@ var failedResultSchema = external_exports.object({
17595
17690
  ...originMarker,
17596
17691
  error: errorSchema,
17597
17692
  authorization: pendingAuthorizationViewSchema.optional(),
17598
- account: confirmedAccountSchema.optional()
17693
+ account: confirmedAccountSchema.optional(),
17694
+ support: supportDataSchema.optional()
17599
17695
  });
17600
17696
  var cliResultSchema = external_exports.union([successfulResultSchema, failedResultSchema]);
17601
17697
 
@@ -18060,6 +18156,9 @@ async function readReadiness(context, accessToken) {
18060
18156
  responseLimitBytes: BUSINESS_REVIEW_RESPONSE_LIMIT_BYTES
18061
18157
  }), ProductReadinessResponseSchema);
18062
18158
  }
18159
+ async function sendSupportMessage(context, accessToken, message) {
18160
+ return interpretReview(await send(context, { method: "POST", path: "/api/v1/support", bearer: accessToken, body: SupportMessageSchema.parse(message) }), SupportMessageResponseSchema);
18161
+ }
18063
18162
 
18064
18163
  // src/refresh.ts
18065
18164
  import { randomBytes as randomBytes2 } from "node:crypto";
@@ -19210,7 +19309,7 @@ async function submitHarnessObservation(context, path) {
19210
19309
 
19211
19310
  // src/commands.ts
19212
19311
  import { access } from "node:fs/promises";
19213
- import { fileURLToPath } from "node:url";
19312
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
19214
19313
 
19215
19314
  // src/onboarding-guide.ts
19216
19315
  var READING_POLL_MS = 6e4;
@@ -20275,6 +20374,111 @@ function memoryText(data) {
20275
20374
  `;
20276
20375
  }
20277
20376
 
20377
+ // src/support-commands.ts
20378
+ import { createHash as createHash2 } from "node:crypto";
20379
+ import { release } from "node:os";
20380
+
20381
+ // src/last-error.ts
20382
+ import { readFile as readFile6, writeFile as writeFile3 } from "node:fs/promises";
20383
+ import { join as join4 } from "node:path";
20384
+ var fileIn = (stateDirectory) => join4(stateDirectory, "last-error.json");
20385
+ async function recordLastError(stateDirectory, error51) {
20386
+ try {
20387
+ const parsed = lastErrorSchema.safeParse({ ...error51, at: (/* @__PURE__ */ new Date()).toISOString() });
20388
+ if (!parsed.success) return;
20389
+ await ensureStateDirectory(stateDirectory);
20390
+ await writeFile3(fileIn(stateDirectory), JSON.stringify(parsed.data), { mode: 384 });
20391
+ } catch {
20392
+ }
20393
+ }
20394
+ async function readLastError(stateDirectory) {
20395
+ try {
20396
+ const parsed = lastErrorSchema.safeParse(JSON.parse(await readFile6(fileIn(stateDirectory), "utf8")));
20397
+ return parsed.success ? parsed.data : null;
20398
+ } catch {
20399
+ return null;
20400
+ }
20401
+ }
20402
+
20403
+ // src/version.ts
20404
+ var cliVersion = "0.1.9";
20405
+
20406
+ // src/support-commands.ts
20407
+ async function probeServer(origin, timeoutMs) {
20408
+ try {
20409
+ const response = await exchange({
20410
+ url: new URL("/api/health", origin.origin),
20411
+ method: "GET",
20412
+ headers: { accept: "application/json" },
20413
+ timeoutMs
20414
+ });
20415
+ return response.status === 200 ? "reachable" : "degraded";
20416
+ } catch {
20417
+ return "unreachable";
20418
+ }
20419
+ }
20420
+ async function sendSupport(context, input) {
20421
+ const lastError = await readLastError(context.stateDirectory);
20422
+ const diagnostic = { cliVersion, node: process.version, os: `${process.platform} ${release()} ${process.arch}`, lastError };
20423
+ let account2 = null;
20424
+ let reason = null;
20425
+ try {
20426
+ const located = await locateCredential(context);
20427
+ if (located === null) {
20428
+ reason = "AUTH_REQUIRED";
20429
+ } else {
20430
+ account2 = located.credential.account.email;
20431
+ const requestId = createHash2("sha256").update(JSON.stringify([
20432
+ context.origin.origin,
20433
+ account2,
20434
+ input.message,
20435
+ input.tag,
20436
+ (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
20437
+ ])).digest("hex").slice(0, 32);
20438
+ const { result } = await useCredential(
20439
+ context,
20440
+ located,
20441
+ (token) => sendSupportMessage(context, token, { requestId, ...input, diagnostic })
20442
+ );
20443
+ if (!result.ok) reason = result.failure.httpStatus >= 500 ? "SERVICE_UNAVAILABLE" : result.failure.code;
20444
+ }
20445
+ } catch (error51) {
20446
+ reason = error51 instanceof CliError ? error51.code : "INTERNAL_ERROR";
20447
+ }
20448
+ const diagnosticText = [
20449
+ `Message: ${input.message}`,
20450
+ `Tag: ${input.tag ?? "general"}`,
20451
+ `Obi CLI ${diagnostic.cliVersion} \xB7 Node ${diagnostic.node} \xB7 ${diagnostic.os}`,
20452
+ `Last error: ${lastError === null ? "none" : `${lastError.code} (${lastError.command}) at ${lastError.at}`}`,
20453
+ `Account: ${account2 ?? "not signed in"}`
20454
+ ].join("\n");
20455
+ const data = {
20456
+ delivery: reason === null ? "sent" : "not_sent",
20457
+ reason,
20458
+ supportEmail: SUPPORT_EMAIL,
20459
+ mailto: `mailto:${SUPPORT_EMAIL}`,
20460
+ tag: input.tag,
20461
+ account: account2,
20462
+ origin: context.origin.origin,
20463
+ diagnosticText
20464
+ };
20465
+ if (reason === null) return data;
20466
+ throw new CliError({
20467
+ code: "SUPPORT_NOT_SENT",
20468
+ message: `The message was not sent to Obi support (${reason}).`,
20469
+ exitCode: reason === "AUTH_REQUIRED" ? 3 : 6,
20470
+ retryable: reason !== "AUTH_REQUIRED",
20471
+ mode: "product",
20472
+ command: "support",
20473
+ nextAction: `Write to ${SUPPORT_EMAIL} and paste the block below (support.diagnosticText with --json). Never add a token or a credential file.`,
20474
+ support: data
20475
+ });
20476
+ }
20477
+ function supportText(data) {
20478
+ return `Sent to Obi support (${data.supportEmail}). The reply goes to ${sanitizeHumanText(data.account ?? "the account email")}.
20479
+ `;
20480
+ }
20481
+
20278
20482
  // src/scan-output.ts
20279
20483
  function heading(data) {
20280
20484
  const scored = `${data.scoredCells}/${data.expectedCells} answers scored from question version ${data.candidateVersion ?? "none"}.`;
@@ -20399,6 +20603,7 @@ function failureText(result) {
20399
20603
  lines.push(`Approving account: ${sanitizeHumanText(result.account.email)} (${result.account.role}).`);
20400
20604
  }
20401
20605
  lines.push(sanitizeHumanText(result.error.nextAction));
20606
+ if (result.support !== void 0) lines.push("", ...result.support.diagnosticText.split("\n").map(sanitizeHumanText));
20402
20607
  return `${lines.join("\n")}
20403
20608
  `;
20404
20609
  }
@@ -20441,11 +20646,12 @@ function toHumanOutput(result) {
20441
20646
  return `Obi \xB7 AI Search Employee
20442
20647
 
20443
20648
  Get started
20444
- 1. obi skill install
20445
- 2. In a new Claude Code or Cursor chat, type /obi.
20649
+ 1. In Claude Code or Cursor, type /obi.
20446
20650
  In Codex CLI, type $obi or choose Obi from /skills.
20447
20651
  In the desktop app, select Obi from the skills picker.
20448
- 3. Give Obi a website or ask to continue saved work.
20652
+ In OpenCode, type @obi.
20653
+ 2. Give Obi a website or ask to continue saved work.
20654
+ Every obi command registers the Skill; obi skill install repairs it.
20449
20655
 
20450
20656
  Commands
20451
20657
  ${usage2}
@@ -20457,11 +20663,11 @@ ${usage2}
20457
20663
  }
20458
20664
  if (result.command === "skill") {
20459
20665
  return result.data.action === "path" ? `${sanitizeHumanText(result.data.path)}
20460
- ` : `Obi Skill registered locally. In a new Claude Code or Cursor chat, type /obi. In Codex CLI, type $obi or choose Obi from /skills. In the desktop app, select Obi from the skills picker.
20666
+ ` : `Obi Skill registered locally. In a new Claude Code or Cursor chat, type /obi. In Codex CLI, type $obi or choose Obi from /skills. In the desktop app, select Obi from the skills picker. In OpenCode, type @obi.
20461
20667
  ${result.data.removed.length > 0 ? "Earlier Skill links of this package under another name were removed; use /obi.\n" : ""}`;
20462
20668
  }
20463
20669
  if (result.command === "capabilities") {
20464
- return "Available: business, capabilities, credentials, doctor, eval, harness, login, logout, memory, questions, readiness, research, skill, status, strategies. Scan execution requires server authorization and a separate customer decision.\n";
20670
+ return "Available: business, capabilities, credentials, doctor, eval, harness, login, logout, memory, questions, readiness, research, skill, status, strategies, support. Scan execution requires server authorization and a separate customer decision.\n";
20465
20671
  }
20466
20672
  if (result.command === "eval") {
20467
20673
  if ("evaluation" in result.data) {
@@ -20476,6 +20682,7 @@ Next: ${sanitizeHumanText(result.data.nextStep)}
20476
20682
  return result.data.action === "list" ? connectionListText(result.data) : revocationText(result.data);
20477
20683
  }
20478
20684
  if (result.command === "memory") return memoryText(result.data);
20685
+ if (result.command === "support") return supportText(result.data);
20479
20686
  if (result.command === "doctor") {
20480
20687
  if (result.mode === "test") {
20481
20688
  return `Synthetic test protocol reachable. State: ${String(result.data.state)}.
@@ -20483,7 +20690,12 @@ Next: ${sanitizeHumanText(result.data.nextStep)}
20483
20690
  }
20484
20691
  const storage = result.data.credentialStorage === "none" ? "no stored connection" : `connection stored in ${storageLabels[result.data.credentialStorage]}`;
20485
20692
  const video = result.data.videoTools.ytDlp ? `video reading available${result.data.videoTools.ffmpeg ? " with frames" : " (captions only; frames need ffmpeg)"}` : "video reading needs yt-dlp";
20486
- return `Local diagnostic completed for ${sanitizeHumanText(result.data.origin)}: ${storage}; ${video}. No request was sent.
20693
+ const server = {
20694
+ reachable: "Obi server reachable.",
20695
+ degraded: `Obi server degraded: try again later, and write to ${SUPPORT_EMAIL} if it lasts.`,
20696
+ unreachable: `Obi server unreachable: check the network connection, and write to ${SUPPORT_EMAIL} if it lasts.`
20697
+ }[result.data.server];
20698
+ return `Local diagnostic completed for ${sanitizeHumanText(result.data.origin)}: ${storage}; ${video}. ${server}
20487
20699
  `;
20488
20700
  }
20489
20701
  if (result.command === "login") {
@@ -20819,9 +21031,6 @@ async function confirmAccount(account2) {
20819
21031
  }
20820
21032
  }
20821
21033
 
20822
- // src/version.ts
20823
- var cliVersion = "0.1.8";
20824
-
20825
21034
  // src/login.ts
20826
21035
  var loginRequestTimeoutMs = 1e4;
20827
21036
  var maxLinkLength = 200;
@@ -21031,7 +21240,7 @@ function accountMismatch(account2, revoked, expected) {
21031
21240
  }
21032
21241
  async function confirmAndStore(context, store, pair, receivedAt, expectedEmail) {
21033
21242
  const api = context;
21034
- const release2 = guardNewFamily(api, pair);
21243
+ const release3 = guardNewFamily(api, pair);
21035
21244
  let stored = false;
21036
21245
  try {
21037
21246
  const status = await readAccountStatus(api, pair.accessToken);
@@ -21062,7 +21271,7 @@ async function confirmAndStore(context, store, pair, receivedAt, expectedEmail)
21062
21271
  }
21063
21272
  await store.writeCredential(credentialFromPair(context.origin.origin, pair, confirmed, receivedAt));
21064
21273
  stored = true;
21065
- release2();
21274
+ release3();
21066
21275
  });
21067
21276
  try {
21068
21277
  await store.removePending();
@@ -21082,7 +21291,7 @@ async function confirmAndStore(context, store, pair, receivedAt, expectedEmail)
21082
21291
  }
21083
21292
  throw error51;
21084
21293
  } finally {
21085
- release2();
21294
+ release3();
21086
21295
  }
21087
21296
  }
21088
21297
  async function login(session, options) {
@@ -21221,12 +21430,12 @@ async function proceedToFirstScan(context, candidateVersion) {
21221
21430
  }
21222
21431
 
21223
21432
  // src/research-commands.ts
21224
- import { readFile as readFile6, stat as stat6 } from "node:fs/promises";
21433
+ import { readFile as readFile7, stat as stat6 } from "node:fs/promises";
21225
21434
  import { isDeepStrictEqual as isDeepStrictEqual2 } from "node:util";
21226
21435
  import { resolve as resolve2 } from "node:path";
21227
21436
 
21228
21437
  // ../geo-engine/src/geo/host-acquisition.ts
21229
- import { createHash as createHash5 } from "node:crypto";
21438
+ import { createHash as createHash6 } from "node:crypto";
21230
21439
 
21231
21440
  // ../geo-engine/src/url-safety.ts
21232
21441
  var PRIVATE_IP_RE = /^(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|0\.)/;
@@ -21397,7 +21606,7 @@ async function safeFetch(url3, opts = {}) {
21397
21606
  if (!REDIRECT_STATUSES.has(res.status)) return res;
21398
21607
  const location = res.headers.get("location");
21399
21608
  if (!location) return res;
21400
- await release(res);
21609
+ await release2(res);
21401
21610
  let next;
21402
21611
  try {
21403
21612
  next = new URL(location, current).href;
@@ -21408,7 +21617,7 @@ async function safeFetch(url3, opts = {}) {
21408
21617
  }
21409
21618
  throw new TooManyRedirectsError(url3, maxRedirects);
21410
21619
  }
21411
- async function release(res) {
21620
+ async function release2(res) {
21412
21621
  await res.body?.cancel().catch((err) => {
21413
21622
  console.warn("[safe-fetch] cancelling an unread body failed:", err);
21414
21623
  });
@@ -21416,7 +21625,7 @@ async function release(res) {
21416
21625
  async function readCapped(res, maxBytes = MAX_RESPONSE_BYTES) {
21417
21626
  const declared = Number(res.headers.get("content-length"));
21418
21627
  if (Number.isFinite(declared) && declared > maxBytes) {
21419
- await release(res);
21628
+ await release2(res);
21420
21629
  return "";
21421
21630
  }
21422
21631
  if (!res.body) return (await res.text()).slice(0, maxBytes);
@@ -21582,7 +21791,7 @@ function createVettedTransport(options = {}) {
21582
21791
  }
21583
21792
 
21584
21793
  // ../geo-engine/src/geo/page-evidence.ts
21585
- import { createHash as createHash2 } from "node:crypto";
21794
+ import { createHash as createHash3 } from "node:crypto";
21586
21795
 
21587
21796
  // ../node_modules/parse5/dist/common/unicode.js
21588
21797
  var UNDEFINED_CODE_POINTS = /* @__PURE__ */ new Set([
@@ -29502,7 +29711,7 @@ function* descendants(node) {
29502
29711
  }
29503
29712
  }
29504
29713
  function sha256(s) {
29505
- return createHash2("sha256").update(s).digest("hex");
29714
+ return createHash3("sha256").update(s).digest("hex");
29506
29715
  }
29507
29716
  var SKIPPED = /* @__PURE__ */ new Set(["script", "style", "noscript", "template", "svg", "iframe", "object", "canvas", "select", "head"]);
29508
29717
  var HEADING = /^h([1-6])$/;
@@ -29793,7 +30002,7 @@ function headingTexts(html, level) {
29793
30002
  }
29794
30003
 
29795
30004
  // ../geo-engine/src/geo/selected-page-evidence.ts
29796
- import { createHash as createHash3 } from "node:crypto";
30005
+ import { createHash as createHash4 } from "node:crypto";
29797
30006
 
29798
30007
  // ../geo-engine/src/geo/material-fact-contract.ts
29799
30008
  var text = external_exports.string().trim().min(1);
@@ -29899,10 +30108,10 @@ var SelectedPageEvidenceSchema = external_exports.strictObject({
29899
30108
  function selectedPageId(pageUrl) {
29900
30109
  const selected = new URL(pageUrl);
29901
30110
  selected.hash = "";
29902
- return `page:${createHash3("sha256").update(selected.href).digest("hex")}`;
30111
+ return `page:${createHash4("sha256").update(selected.href).digest("hex")}`;
29903
30112
  }
29904
30113
  function observationBodyHash(body) {
29905
- return createHash3("sha256").update(body).digest("hex");
30114
+ return createHash4("sha256").update(body).digest("hex");
29906
30115
  }
29907
30116
  function sameSelectedHost(selectedUrl, finalUrl) {
29908
30117
  const host = (value) => new URL(value).hostname.toLowerCase().replace(/^www\./, "");
@@ -30354,14 +30563,14 @@ function evaluateAccess(parsed, token, url3, fallbackTokens = []) {
30354
30563
  }
30355
30564
 
30356
30565
  // ../geo-engine/src/geo/readiness/findings.ts
30357
- import { createHash as createHash4 } from "node:crypto";
30566
+ import { createHash as createHash5 } from "node:crypto";
30358
30567
  function issueId(origin, checkId, scopeKey) {
30359
- return createHash4("sha256").update(`${origin.toLowerCase()}
30568
+ return createHash5("sha256").update(`${origin.toLowerCase()}
30360
30569
  ${checkId}
30361
30570
  ${scopeKey}`).digest("hex").slice(0, 16);
30362
30571
  }
30363
30572
  function fingerprint(body) {
30364
- return body === null ? null : createHash4("sha256").update(body).digest("hex");
30573
+ return body === null ? null : createHash5("sha256").update(body).digest("hex");
30365
30574
  }
30366
30575
  function makeFinding({ origin, scopeKey, policyChoice = false, materiality = "site", ...rest }) {
30367
30576
  return { id: issueId(origin, rest.checkId, scopeKey), policyChoice, materiality, ...rest };
@@ -30864,7 +31073,7 @@ function validateState(raw) {
30864
31073
  return state;
30865
31074
  }
30866
31075
  function matchesFingerprint(observation) {
30867
- return observation.body === null ? observation.bodyHash === null : observation.bodyHash === createHash5("sha256").update(observation.body).digest("hex");
31076
+ return observation.body === null ? observation.bodyHash === null : observation.bodyHash === createHash6("sha256").update(observation.body).digest("hex");
30868
31077
  }
30869
31078
  function pendingProbeRequests(state) {
30870
31079
  const home = state.resources.find((resource) => resource.kind === "page" && resource.url === `${state.origin}/`);
@@ -32635,7 +32844,7 @@ async function readJson(path, maxBytes = maxCorpusBytes) {
32635
32844
  try {
32636
32845
  const info = await stat6(path);
32637
32846
  if (!info.isFile() || info.size > maxBytes) throw fileError(`The research file is not a regular JSON file within the ${maxBytes / 1024 / 1024} MiB local limit.`);
32638
- const content = await readFile6(path);
32847
+ const content = await readFile7(path);
32639
32848
  if (content.byteLength > maxBytes) throw fileError(`The research file exceeds the ${maxBytes / 1024 / 1024} MiB local limit.`);
32640
32849
  return JSON.parse(content.toString("utf8"));
32641
32850
  } catch (error51) {
@@ -32823,8 +33032,8 @@ async function captureResearch(input, checkpoint, output) {
32823
33032
 
32824
33033
  // src/research-video.ts
32825
33034
  import { spawn as spawn2 } from "node:child_process";
32826
- import { mkdir as mkdir2, readdir as readdir3, readFile as readFile7, rm as rm2 } from "node:fs/promises";
32827
- import { join as join4, resolve as resolve3 } from "node:path";
33035
+ import { mkdir as mkdir2, readdir as readdir3, readFile as readFile8, rm as rm2 } from "node:fs/promises";
33036
+ import { join as join5, resolve as resolve3 } from "node:path";
32828
33037
  var maxToolOutputBytes = 256 * 1024;
32829
33038
  var runTool = (executable, args, timeoutMs) => new Promise((done, fail) => {
32830
33039
  const child = spawn2(executable, [...args], { stdio: ["ignore", "pipe", "pipe"], shell: false });
@@ -32923,12 +33132,12 @@ async function readVideo(rawUrl, outputDir, frames, runner = runTool) {
32923
33132
  const directory = resolve3(outputDir);
32924
33133
  await mkdir2(directory, { recursive: true });
32925
33134
  const base = ["--ignore-config", "--no-playlist", "--quiet", "--no-warnings"];
32926
- const target = ["-o", join4(templateSafe(directory), "video.%(ext)s")];
33135
+ const target = ["-o", join5(templateSafe(directory), "video.%(ext)s")];
32927
33136
  const failed = (message, nextAction) => videoError("RESEARCH_VIDEO_FAILED", message, nextAction);
32928
33137
  const metadata = await runner("yt-dlp", [...base, "--skip-download", "--write-info-json", ...target, url3.href], 18e4).catch((error51) => {
32929
33138
  throw error51.code === "ENOENT" ? missingTool(frames) : failed("yt-dlp did not finish reading this video page.", "Retry once; a slow or blocked network leaves the video unread.");
32930
33139
  });
32931
- const info = metadata.exitCode === 0 ? JSON.parse(await readFile7(join4(directory, "video.info.json"), "utf8")) : null;
33140
+ const info = metadata.exitCode === 0 ? JSON.parse(await readFile8(join5(directory, "video.info.json"), "utf8")) : null;
32932
33141
  if (!info) {
32933
33142
  throw failed("yt-dlp could not read this video page.", "Check the address in a browser; a private or region-locked video stays unread.");
32934
33143
  }
@@ -32950,11 +33159,11 @@ async function readVideo(rawUrl, outputDir, frames, runner = runTool) {
32950
33159
  if (subtitles?.exitCode !== 0 || !file2) {
32951
33160
  throw failed("The video has captions but their download failed.", "Retry in a minute; the platform may be rate-limiting.");
32952
33161
  }
32953
- segments = parseVtt(await readFile7(join4(directory, file2), "utf8"));
33162
+ segments = parseVtt(await readFile8(join5(directory, file2), "utf8"));
32954
33163
  }
32955
33164
  const duration3 = typeof info.duration === "number" ? info.duration : null;
32956
33165
  const frameFiles = [];
32957
- const output = join4(directory, "transcript.json");
33166
+ const output = join5(directory, "transcript.json");
32958
33167
  const save = () => writeJsonAtomic(
32959
33168
  output,
32960
33169
  {
@@ -32974,7 +33183,7 @@ async function readVideo(rawUrl, outputDir, frames, runner = runTool) {
32974
33183
  throw failed("The video's length is unknown, so frames cannot be spaced.", "Retry with --frames 0 to keep the captions only.");
32975
33184
  }
32976
33185
  if (frames > 0 && duration3) {
32977
- const removeMedia = async () => Promise.all((await readdir3(directory)).filter((file2) => file2.startsWith("media.")).map((file2) => rm2(join4(directory, file2), { force: true })));
33186
+ const removeMedia = async () => Promise.all((await readdir3(directory)).filter((file2) => file2.startsWith("media.")).map((file2) => rm2(join5(directory, file2), { force: true })));
32978
33187
  await removeMedia();
32979
33188
  try {
32980
33189
  const download = await runner("yt-dlp", [
@@ -32982,7 +33191,7 @@ async function readVideo(rawUrl, outputDir, frames, runner = runTool) {
32982
33191
  "-f",
32983
33192
  "b[height<=480]/bv*[height<=480]/w",
32984
33193
  "-o",
32985
- join4(templateSafe(directory), "media.%(ext)s"),
33194
+ join5(templateSafe(directory), "media.%(ext)s"),
32986
33195
  url3.href
32987
33196
  ], 6e5).catch(() => {
32988
33197
  throw failed("The video download did not finish.", "Retry with --frames 0 to keep the captions only.");
@@ -33002,14 +33211,14 @@ async function readVideo(rawUrl, outputDir, frames, runner = runTool) {
33002
33211
  "-ss",
33003
33212
  String(atSeconds),
33004
33213
  "-i",
33005
- join4(directory, media),
33214
+ join5(directory, media),
33006
33215
  "-frames:v",
33007
33216
  "1",
33008
33217
  "-vf",
33009
33218
  "scale=1024:-2",
33010
- join4(directory, file2)
33219
+ join5(directory, file2)
33011
33220
  ], 6e4).catch(() => null);
33012
- if (extracted?.exitCode === 0) frameFiles.push({ file: join4(directory, file2), atSeconds });
33221
+ if (extracted?.exitCode === 0) frameFiles.push({ file: join5(directory, file2), atSeconds });
33013
33222
  }
33014
33223
  } finally {
33015
33224
  await removeMedia();
@@ -33128,13 +33337,15 @@ async function showAlphaStrategies(context) {
33128
33337
  // src/skill-install.ts
33129
33338
  import { lstat as lstat3, mkdir as mkdir3, readlink, realpath, symlink, unlink as unlink5 } from "node:fs/promises";
33130
33339
  import { homedir as homedir2 } from "node:os";
33131
- import { join as join5, sep } from "node:path";
33340
+ import { join as join6, sep } from "node:path";
33341
+ import { fileURLToPath } from "node:url";
33132
33342
  var skillDirectories = [".agents/skills", ".claude/skills"];
33133
33343
  var alphaSkillName = "heysurface";
33344
+ var messageLimit = 300;
33134
33345
  function installError(message) {
33135
33346
  return new CliError({
33136
33347
  code: "SKILL_INSTALL_FAILED",
33137
- message,
33348
+ message: message.length > messageLimit ? `${message.slice(0, messageLimit - 1)}\u2026` : message,
33138
33349
  exitCode: 4,
33139
33350
  retryable: false,
33140
33351
  nextAction: "Check the personal Skill directories and run obi skill install again. Existing files are never replaced.",
@@ -33164,7 +33375,7 @@ async function linksIntoPackage(path, packageRoot) {
33164
33375
  return target === packageRoot || target.startsWith(`${packageRoot}${sep}`);
33165
33376
  } catch {
33166
33377
  try {
33167
- return await readlink(path) === join5(packageRoot, "skills", alphaSkillName);
33378
+ return await readlink(path) === join6(packageRoot, "skills", alphaSkillName);
33168
33379
  } catch {
33169
33380
  return false;
33170
33381
  }
@@ -33173,37 +33384,70 @@ async function linksIntoPackage(path, packageRoot) {
33173
33384
  async function installSkill(sourceDirectory) {
33174
33385
  const source = await realpath(sourceDirectory);
33175
33386
  const home = homedir2();
33176
- const destinations = skillDirectories.map((directory) => join5(home, directory, "obi"));
33387
+ const destinations = skillDirectories.map((directory) => join6(home, directory, "obi"));
33388
+ const registered = async (destination) => {
33389
+ try {
33390
+ return await realpath(destination) === source;
33391
+ } catch {
33392
+ return false;
33393
+ }
33394
+ };
33177
33395
  for (const destination of destinations) {
33178
- if (!await existingPath(destination)) continue;
33396
+ if (!await existingPath(destination) || await registered(destination)) continue;
33397
+ let target = null;
33179
33398
  try {
33180
- if (await realpath(destination) === source) continue;
33399
+ if ((await lstat3(destination)).isSymbolicLink()) target = await readlink(destination);
33181
33400
  } catch {
33401
+ continue;
33182
33402
  }
33183
- throw installError(`A Skill entry already exists at ${destination}.`);
33403
+ throw installError(target === null ? `A Skill entry already exists: a file or folder this package did not create. Move it, then run obi skill install. It is at ${destination}` : `A Skill entry already exists: a link outside this installation. To use this installation, remove that link, then run obi skill install. The link: ${destination} -> ${target}`);
33184
33404
  }
33185
33405
  for (const destination of destinations) {
33186
- if (await existingPath(destination)) continue;
33406
+ if (await registered(destination)) continue;
33187
33407
  try {
33188
- await mkdir3(join5(destination, ".."), { recursive: true });
33408
+ await mkdir3(join6(destination, ".."), { recursive: true });
33189
33409
  await symlink(source, destination, process.platform === "win32" ? "junction" : "dir");
33190
33410
  } catch {
33191
- throw installError(`Could not register the Obi Skill at ${destination}.`);
33411
+ if (await registered(destination)) continue;
33412
+ throw installError(`Could not register the Obi Skill. Check that its folder can be written, then run obi skill install. The link to make: ${destination}`);
33192
33413
  }
33193
33414
  }
33194
33415
  const removed = [];
33195
- const packageRoot = await realpath(join5(source, "..", ".."));
33196
- for (const alpha of skillDirectories.map((directory) => join5(home, directory, alphaSkillName))) {
33416
+ const packageRoot = await realpath(join6(source, "..", ".."));
33417
+ for (const alpha of skillDirectories.map((directory) => join6(home, directory, alphaSkillName))) {
33197
33418
  if (!await linksIntoPackage(alpha, packageRoot)) continue;
33198
33419
  try {
33199
33420
  await unlink5(alpha);
33200
- } catch {
33421
+ } catch (error51) {
33422
+ if (error51.code === "ENOENT") continue;
33201
33423
  throw installError(`Could not retire the earlier Skill link at ${alpha}.`);
33202
33424
  }
33203
33425
  removed.push(alpha);
33204
33426
  }
33205
33427
  return { paths: destinations, removed };
33206
33428
  }
33429
+ function packagedSkillDirectory() {
33430
+ return fileURLToPath(new URL("../skills/obi", import.meta.url));
33431
+ }
33432
+ async function registerSkillLinks(sourceDirectory, env) {
33433
+ if (env.OBI_SKILL_LINKS === "off") return { state: "off" };
33434
+ try {
33435
+ const source = await realpath(sourceDirectory);
33436
+ let present3 = true;
33437
+ for (const directory of skillDirectories) {
33438
+ try {
33439
+ if (await realpath(join6(homedir2(), directory, "obi")) !== source) present3 = false;
33440
+ } catch {
33441
+ present3 = false;
33442
+ }
33443
+ }
33444
+ if (present3) return { state: "present" };
33445
+ await installSkill(sourceDirectory);
33446
+ return { state: "created" };
33447
+ } catch (error51) {
33448
+ return { state: "failed", reason: error51 instanceof CliError ? error51.message : "The Obi Skill could not be registered." };
33449
+ }
33450
+ }
33207
33451
 
33208
33452
  // src/transport.ts
33209
33453
  var loopbackOrigin = /^http:\/\/(127\.0\.0\.1|\[::1\]):([1-9][0-9]{0,4})$/;
@@ -33328,7 +33572,7 @@ async function fetchTestStatus(origin, timeoutMs) {
33328
33572
  }
33329
33573
 
33330
33574
  // src/commands.ts
33331
- var usage = "obi <business|capabilities|credentials|doctor|eval|harness|login|logout|memory|questions|readiness|research|skill|status|strategies> [--json]\n obi eval <start <domain>|list> [--json]\n Evaluation commands: add --eval-case <id> to status, business, harness, readiness, questions and strategies\n obi harness coverage [--json]\n obi harness activate --free [--json]\n obi harness show --stage <business|questions|strategies> [--packs <1-40> for questions review] [--json]\n obi harness submit --stage <business|questions|strategies> --input <file> [--json]\n obi harness outcomes --task-id <UUID> [--cursor <event-id>] [--limit <1-50>] [--json]\n obi harness observe --input <file> [--json]\n obi harness purchase <quote|prepare|status|resume|change> [--input <quote-and-basket.json>; resume: quote ID only] [--confirm-checkout for prepare] [--confirm-change for change] [--json]\n obi harness artifact <save|show|history|export> --work-id <UUID> [--input <JSON file>] [--revision <n>] [--output <new file>] [--cursor <opaque>] [--limit <1-100>] [--json]\n obi harness evidence --work-id <UUID> [--kind claims|sections|measurements] [--cursor <opaque>] [--query <text>] [--url <exact URL>] [--all --output <file>] [--json]\n obi research crawl <domain> --checkpoint <file> --output <file> [--batch-size <1-100>] [--locale <tag>] [--pages priority|all] [--max-pages <n|none>] [--retry-failed|--refresh] [--json]\n obi research capture --input <selected-page-file> --checkpoint <file> --output <corpus-file> [--json]\n obi research readiness --input <corpus-file> --output <report-file> [--json]\n obi research video <url> --output <directory> [--frames <0-30>] [--json]\n obi memory <list|add --kind <preference|constraint|decision> --text <words>|remove <id>> [--eval-case <id>] [--json]\n obi questions show [--json]\n obi questions prepare --confirm-preparation [--json]\n obi questions edit <id> --candidate-version <n> --text <wording> [--json]\n obi questions proceed --candidate-version <n> --confirm-scan [--json]\n obi questions results [--run latest|first|<run ID>] [--from <question position>] [--json]\n obi readiness [--json]\n obi strategies [--json]\n obi skill <path|install> [--json]\n obi credentials <list|revoke <credentialId>> [--force]\n obi business start --confirm-domain <domain>\n obi business show\n obi business discover --snapshot <token> --confirm-discovery\n obi business clarify-identity --snapshot <token> --company <name>\n obi business clarify-fact <itemId> --snapshot <token> --field-path <path> --text <answer> --company <current name> --company-wide [--segment-name <name> for segments.append]\n obi business challenge <itemId> --snapshot <token> --reason <reason>\n obi business correct <itemId> --snapshot <token> --claim-id <id> --field-path <path> --text <affirmative correction> --company <current name> --company-wide\n obi business remove <competitorItemId> --snapshot <token> [--reason <reason>]\n obi business <add-competitor|remove-competitor|readd-competitor> <domain> --snapshot <token> [--reason <reason>]\n obi business confirm --snapshot <token> --confirm-review\n Global options: --json, --timeout-ms <100-30000>; login: --expect-email <email> [--storage file]; status/doctor test: --test-server <origin>";
33575
+ var usage = 'obi <business|capabilities|credentials|doctor|eval|harness|login|logout|memory|questions|readiness|research|skill|status|strategies|support> [--json]\n obi support "<message>" [--tag market-request|scope-request] [--json]\n obi eval <start <domain>|list> [--json]\n Evaluation commands: add --eval-case <id> to status, business, harness, readiness, questions and strategies\n obi harness coverage [--json]\n obi harness activate --free [--json]\n obi harness show --stage <business|questions|strategies> [--packs <1-40> for questions review] [--json]\n obi harness submit --stage <business|questions|strategies> --input <file> [--json]\n obi harness outcomes --task-id <UUID> [--cursor <event-id>] [--limit <1-50>] [--json]\n obi harness observe --input <file> [--json]\n obi harness purchase <quote|prepare|status|resume|change> [--input <quote-and-basket.json>; resume: quote ID only] [--confirm-checkout for prepare] [--confirm-change for change] [--json]\n obi harness artifact <save|show|history|export> --work-id <UUID> [--input <JSON file>] [--revision <n>] [--output <new file>] [--cursor <opaque>] [--limit <1-100>] [--json]\n obi harness evidence --work-id <UUID> [--kind claims|sections|measurements] [--cursor <opaque>] [--query <text>] [--url <exact URL>] [--all --output <file>] [--json]\n obi research crawl <domain> --checkpoint <file> --output <file> [--batch-size <1-100>] [--locale <tag>] [--pages priority|all] [--max-pages <n|none>] [--retry-failed|--refresh] [--json]\n obi research capture --input <selected-page-file> --checkpoint <file> --output <corpus-file> [--json]\n obi research readiness --input <corpus-file> --output <report-file> [--json]\n obi research video <url> --output <directory> [--frames <0-30>] [--json]\n obi memory <list|add --kind <preference|constraint|decision> --text <words>|remove <id>> [--eval-case <id>] [--json]\n obi questions show [--json]\n obi questions prepare --confirm-preparation [--json]\n obi questions edit <id> --candidate-version <n> --text <wording> [--json]\n obi questions proceed --candidate-version <n> --confirm-scan [--json]\n obi questions results [--run latest|first|<run ID>] [--from <question position>] [--json]\n obi readiness [--json]\n obi strategies [--json]\n obi skill <path|install> [--json]\n obi credentials <list|revoke <credentialId>> [--force]\n obi business start --confirm-domain <domain>\n obi business show\n obi business discover --snapshot <token> --confirm-discovery\n obi business clarify-identity --snapshot <token> --company <name>\n obi business clarify-fact <itemId> --snapshot <token> --field-path <path> --text <answer> --company <current name> --company-wide [--segment-name <name> for segments.append]\n obi business challenge <itemId> --snapshot <token> --reason <reason>\n obi business correct <itemId> --snapshot <token> --claim-id <id> --field-path <path> --text <affirmative correction> --company <current name> --company-wide\n obi business remove <competitorItemId> --snapshot <token> [--reason <reason>]\n obi business <add-competitor|remove-competitor|readd-competitor> <domain> --snapshot <token> [--reason <reason>]\n obi business confirm --snapshot <token> --confirm-review\n Global options: --json, --timeout-ms <100-30000>; login: --expect-email <email> [--storage file]; status/doctor test: --test-server <origin>';
33332
33576
  function success2(command, mode, data) {
33333
33577
  return cliResultSchema.parse({ schemaVersion: 1, command, mode, ok: true, data });
33334
33578
  }
@@ -33355,7 +33599,7 @@ function statusResult(status) {
33355
33599
  }
33356
33600
  return success2("status", "test", status);
33357
33601
  }
33358
- async function localDoctor(runtime) {
33602
+ async function localDoctor(runtime, timeoutMs) {
33359
33603
  const located = await locateCredential(runtime);
33360
33604
  return success2("doctor", "local", {
33361
33605
  node: process.version,
@@ -33363,6 +33607,7 @@ async function localDoctor(runtime) {
33363
33607
  origin: runtime.origin.origin,
33364
33608
  credentialStorage: located === null ? "none" : located.store.backend.kind,
33365
33609
  videoTools: await videoTools(),
33610
+ server: await probeServer(runtime.origin, timeoutMs),
33366
33611
  diagnostic: "completed"
33367
33612
  });
33368
33613
  }
@@ -33375,7 +33620,7 @@ async function executeCommand(args, runtime) {
33375
33620
  if (args.command === "help") {
33376
33621
  return success2("help", "local", {
33377
33622
  usage,
33378
- commands: ["business", "capabilities", "credentials", "doctor", "eval", "harness", "login", "logout", "memory", "questions", "readiness", "research", "skill", "status", "strategies"]
33623
+ commands: ["business", "capabilities", "credentials", "doctor", "eval", "harness", "login", "logout", "memory", "questions", "readiness", "research", "skill", "status", "strategies", "support"]
33379
33624
  });
33380
33625
  }
33381
33626
  if (args.command === "version") {
@@ -33384,6 +33629,13 @@ async function executeCommand(args, runtime) {
33384
33629
  if (args.command === "capabilities") {
33385
33630
  return success2("capabilities", "local", {
33386
33631
  version: cliVersion,
33632
+ measurementMarkets: SUPPORTED_MARKETS,
33633
+ catalog: { version: CLI_DOLLAR_CATALOG_VERSION, currency: "usd", monthlyCents: {
33634
+ corePack: CLI_DOLLAR_PRICES.core,
33635
+ claudePerPack: CLI_DOLLAR_PRICES.claude,
33636
+ perplexityPerPack: CLI_DOLLAR_PRICES.perplexity
33637
+ } },
33638
+ support: { email: SUPPORT_EMAIL },
33387
33639
  realLogin: "available",
33388
33640
  paidExecution: "server_authorized",
33389
33641
  expertise: { manifestVersion: AI_SEARCH_COMPETENCY_VERSION, competencies: AI_SEARCH_COMPETENCIES },
@@ -33402,13 +33654,14 @@ async function executeCommand(args, runtime) {
33402
33654
  skill: { actions: ["path", "install"], options: ["--json"] },
33403
33655
  status: { options: ["--json", "--eval-case", "--test-server", "--timeout-ms"] },
33404
33656
  readiness: { options: ["--json", "--eval-case", "--timeout-ms"] },
33405
- strategies: { options: ["--json", "--eval-case", "--timeout-ms"] }
33657
+ strategies: { options: ["--json", "--eval-case", "--timeout-ms"] },
33658
+ support: { tags: [...SUPPORT_TAGS], options: ["--json", "--tag", "--timeout-ms"] }
33406
33659
  }
33407
33660
  });
33408
33661
  }
33409
33662
  if (args.command === "doctor") {
33410
33663
  if (args.testServer === void 0) {
33411
- return localDoctor(runtime);
33664
+ return localDoctor(runtime, args.timeoutMs);
33412
33665
  }
33413
33666
  const status = await fetchTestStatus(args.testServer, args.timeoutMs);
33414
33667
  return success2("doctor", "test", {
@@ -33418,7 +33671,7 @@ async function executeCommand(args, runtime) {
33418
33671
  });
33419
33672
  }
33420
33673
  if (args.command === "skill") {
33421
- const path = fileURLToPath(new URL("../skills/obi/SKILL.md", import.meta.url));
33674
+ const path = fileURLToPath2(new URL("../skills/obi/SKILL.md", import.meta.url));
33422
33675
  try {
33423
33676
  await access(path);
33424
33677
  } catch {
@@ -33431,7 +33684,7 @@ async function executeCommand(args, runtime) {
33431
33684
  mode: "local"
33432
33685
  });
33433
33686
  }
33434
- return success2("skill", "local", args.skillAction === "install" ? { action: "install", ...await installSkill(fileURLToPath(new URL("../skills/obi", import.meta.url))) } : { action: "path", path });
33687
+ return success2("skill", "local", args.skillAction === "install" ? { action: "install", ...await installSkill(fileURLToPath2(new URL("../skills/obi", import.meta.url))) } : { action: "path", path });
33435
33688
  }
33436
33689
  if (args.command === "login") {
33437
33690
  return success2("login", "product", await login(session, {
@@ -33488,6 +33741,9 @@ async function executeCommand(args, runtime) {
33488
33741
  }
33489
33742
  ) : args.researchAction === "video" ? await readVideo(args.researchVideoUrl, args.researchOutput, args.researchFrames ?? 0) : args.researchAction === "capture" ? await captureResearch(args.researchInput, args.researchCheckpoint, args.researchOutput) : await assessResearch(args.researchInput, args.researchOutput));
33490
33743
  }
33744
+ if (args.command === "support") {
33745
+ return success2("support", "product", await sendSupport(session, { message: args.supportMessage, tag: args.supportTag ?? null }));
33746
+ }
33491
33747
  if (args.command === "memory") {
33492
33748
  if (args.memoryAction === "list") return success2("memory", "product", await listMemory(session));
33493
33749
  return success2("memory", "product", await changeMemory(session, args.memoryAction === "add" ? { op: "add", kind: args.memoryKind, text: args.memoryText } : { op: "remove", id: args.memoryEntryId }));
@@ -33540,23 +33796,51 @@ async function executeCommand(args, runtime) {
33540
33796
  return success2("status", "product", await readStatus(session));
33541
33797
  }
33542
33798
 
33799
+ // src/start.ts
33800
+ import { join as join7 } from "node:path";
33801
+ var linkStates = {
33802
+ created: "created",
33803
+ present: "present",
33804
+ off: "off (OBI_SKILL_LINKS), run obi skill install"
33805
+ };
33806
+ function renderStart(input) {
33807
+ const links = input.registration.state === "failed" ? `failed. ${sanitizeHumanText(input.registration.reason)}` : `${linkStates[input.registration.state]}.`;
33808
+ const account2 = input.accountEmail === null ? [
33809
+ "Account: not connected. Connect: obi login --expect-email <the user's email> (prints an address and a code for the user).",
33810
+ "Then: obi status --json"
33811
+ ] : [`Account: connected as ${sanitizeHumanText(input.accountEmail)}. Its state: obi status --json`];
33812
+ return [
33813
+ `Obi CLI ${cliVersion}, npm package @hireobi/cli. Skill links: ${links}`,
33814
+ `Agent guide, a local file in this package: ${sanitizeHumanText(join7(input.skillDirectory, "SKILL.md"))}`,
33815
+ ...account2,
33816
+ input.registration.state === "created" || input.registration.state === "present" ? "In the agent: /obi (Claude Code, Cursor), $obi (Codex) or @obi (OpenCode); /reload-skills in Claude Code if /obi is missing." : "In the agent: /obi, $obi and @obi exist once the Skill links do; until then, read the guide above.",
33817
+ 'The obi command comes only from npm i -g @hireobi/cli; the npm package named "obi" is unrelated.'
33818
+ ].join("\n") + "\n";
33819
+ }
33820
+
33543
33821
  // src/welcome.ts
33544
33822
  var copy = {
33545
33823
  en: {
33546
33824
  tagline: "Your AI Search employee.",
33547
33825
  greeting: "Hi, I'm Obi. Let's get you discovered.",
33548
- install: "Get started: obi skill install",
33826
+ ready: "I'm in your assistants: Claude Code, Codex, Cursor and OpenCode.",
33827
+ off: "Get started: obi skill install",
33828
+ failed: "I could not join your assistants.",
33549
33829
  claude: "Claude Code / Cursor: /obi",
33550
33830
  codex: "Codex: $obi or the skills picker",
33831
+ opencode: "OpenCode: @obi",
33551
33832
  next: "Then give me your website in your assistant's chat.",
33552
33833
  help: "All commands: obi --help"
33553
33834
  },
33554
33835
  fr: {
33555
33836
  tagline: "Ton agent AI Search.",
33556
33837
  greeting: "Salut, moi c'est Obi. Faisons conna\xEEtre ton entreprise.",
33557
- install: "Pour d\xE9marrer : obi skill install",
33838
+ ready: "Je suis dans tes assistants : Claude Code, Codex, Cursor et OpenCode.",
33839
+ off: "Pour d\xE9marrer : obi skill install",
33840
+ failed: "Je n'ai pas pu rejoindre tes assistants.",
33558
33841
  claude: "Claude Code / Cursor : /obi",
33559
33842
  codex: "Codex : $obi ou le s\xE9lecteur de skills",
33843
+ opencode: "OpenCode : @obi",
33560
33844
  next: "Donne-moi ensuite ton site dans ton assistant.",
33561
33845
  help: "Toutes les commandes : obi --help"
33562
33846
  }
@@ -33610,7 +33894,7 @@ function mascotLine(row, depth) {
33610
33894
  return `${color(top, false, depth)}${color(bottom, true, depth)}\u2580`;
33611
33895
  }).join("") + reset;
33612
33896
  }
33613
- function renderWelcome(env, columns, colorDepth) {
33897
+ function renderWelcome(env, columns, colorDepth, registration) {
33614
33898
  const locale = [env.OBI_LANGUAGE, env.LC_ALL, env.LC_MESSAGES, env.LANG].find((value) => value?.trim())?.trim().toLowerCase() ?? "en";
33615
33899
  const text2 = copy[/^fr(?:[-_.@]|$)/.test(locale) ? "fr" : "en"];
33616
33900
  const colorful = columns >= 64 && colorDepth >= 8 && env.TERM !== "dumb" && !env.NO_COLOR;
@@ -33619,14 +33903,16 @@ function renderWelcome(env, columns, colorDepth) {
33619
33903
  return ` ${mascotLine(row, colorDepth)} ${aside}`;
33620
33904
  }).join("\n") : `Obi \xB7 ${cliVersion}
33621
33905
  ${text2.tagline}`;
33906
+ const skill = registration.state === "failed" ? `${text2.failed} ${sanitizeHumanText(registration.reason)}` : registration.state === "off" ? text2.off : text2.ready;
33622
33907
  return `
33623
33908
  ${title}
33624
33909
 
33625
33910
  ${text2.greeting}
33626
33911
 
33627
- ${text2.install}
33912
+ ${skill}
33628
33913
  ${text2.claude}
33629
33914
  ${text2.codex}
33915
+ ${text2.opencode}
33630
33916
 
33631
33917
  ${text2.next}
33632
33918
  ${text2.help}
@@ -33648,7 +33934,8 @@ function failureResult(error51, command, mode) {
33648
33934
  nextAction: error51.nextAction
33649
33935
  },
33650
33936
  authorization: error51.authorization,
33651
- account: error51.account
33937
+ account: error51.account,
33938
+ support: error51.support
33652
33939
  });
33653
33940
  }
33654
33941
  function markOrigin(result, origin) {
@@ -33658,6 +33945,8 @@ async function runCli() {
33658
33945
  const argv = process.argv.slice(2);
33659
33946
  const json2 = wantsJson(argv);
33660
33947
  const welcome = argv.length === 0 && process.stdin.isTTY === true && process.stdout.isTTY === true;
33948
+ const start = argv.length === 0 && !welcome;
33949
+ const registration = await registerSkillLinks(packagedSkillDirectory(), process.env);
33661
33950
  let command = "unknown";
33662
33951
  let mode = commandMode(
33663
33952
  void 0,
@@ -33675,14 +33964,21 @@ async function runCli() {
33675
33964
  return resolved;
33676
33965
  };
33677
33966
  try {
33678
- const args = parseCommandArguments(welcome ? ["--help"] : argv);
33967
+ const args = parseCommandArguments(welcome || start ? ["--help"] : argv);
33679
33968
  command = args.command;
33680
33969
  mode = commandMode(args.command, args.testServer !== void 0);
33681
33970
  origin = activeOrigin();
33682
- const result = await executeCommand(args, {
33683
- origin,
33684
- stateDirectory: await prepareStateDirectory(process.env)
33685
- });
33971
+ const runtime = { origin, stateDirectory: await prepareStateDirectory(process.env) };
33972
+ if (start) {
33973
+ const located = await locateCredential(runtime, "skip");
33974
+ process.stdout.write(renderStart({
33975
+ registration,
33976
+ skillDirectory: packagedSkillDirectory(),
33977
+ accountEmail: located === null ? null : located.credential.account.email
33978
+ }));
33979
+ return;
33980
+ }
33981
+ const result = await executeCommand(args, runtime);
33686
33982
  const evaluationCaseId = args.evaluationCaseId;
33687
33983
  const scopeNext = (data) => ({
33688
33984
  ...data,
@@ -33694,7 +33990,7 @@ async function runCli() {
33694
33990
  ...result.ok && result.command === "status" && result.mode === "product" ? { data: scopeNext(result.data) } : result.ok && result.command === "business" && result.data.action === "start" ? { data: scopeNext(result.data) } : {}
33695
33991
  }) : result;
33696
33992
  if (scoped.ok && welcome) {
33697
- process.stdout.write(renderWelcome(process.env, process.stdout.columns, process.stdout.getColorDepth()));
33993
+ process.stdout.write(renderWelcome(process.env, process.stdout.columns, process.stdout.getColorDepth(), registration));
33698
33994
  } else {
33699
33995
  writeResult(markOrigin(scoped, origin), args.json);
33700
33996
  }
@@ -33712,7 +34008,14 @@ async function runCli() {
33712
34008
  retryable: false,
33713
34009
  nextAction: "Run the command again or inspect the local CLI installation."
33714
34010
  });
33715
- writeResult(markOrigin(failureResult(knownError, knownError.command ?? command, mode), origin), json2);
34011
+ const failure2 = failureResult(knownError, knownError.command ?? command, mode);
34012
+ if (failure2.mode !== "test" && failure2.command !== "support" && knownError.code !== "AUTHORIZATION_PENDING") {
34013
+ await prepareStateDirectory(process.env).then((directory) => recordLastError(
34014
+ directory,
34015
+ { code: knownError.code, command: failure2.command }
34016
+ ), () => void 0);
34017
+ }
34018
+ writeResult(markOrigin(failure2, origin), json2);
33716
34019
  process.exitCode = knownError.exitCode;
33717
34020
  } finally {
33718
34021
  closeIdleConnections();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hireobi/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Obi, the AI Search Employee: CLI and Skill for your AI assistant",
5
5
  "keywords": [
6
6
  "obi",
@@ -33,7 +33,7 @@ Obi remembers what the customer tells it to remember, across hosts and sessions:
33
33
 
34
34
  Check `obi capabilities --json`, then `obi memory list --json`. Keep the account context established by the conversation or CLI result. For the ordinary connected business, use `obi status --json` and work commands without `--eval-case`; do not look for an evaluation ID. Only an explicitly established public-company evaluation uses `--eval-case <id>` on every account or work read and write, including `status`. `obi eval list --json` recovers a missing ID for that already selected evaluation; a missing ID alone never establishes one. If the machine is unconnected, follow [connection](references/connection.md). If the customer names a public company to evaluate, use its domain only after they choose it inside this conversation. An eligible operator starts an isolated case with `obi eval start <domain> --json`. Never use the ordinary account's old results for a new domain.
35
35
 
36
- When no domain is chosen, offer to continue the connected company's saved work or evaluate a new public domain. Ask once for the research mission: the domain, intended buyer or market if known, and whether you may read its public pages and relevant public competitors. Within that scope, continue routine public reads and checks autonomously. Read [adaptive research](references/host-work.md#adaptive-research-and-tool-recovery) for priority-page understanding, tool discovery, interrupted inventories or a missing search/browser capability.
36
+ When no domain is chosen, offer to continue the connected company's saved work or evaluate a new public domain. Ask once for the research mission: the domain, intended buyer or market if known, and whether you may read its public pages and relevant public competitors. Before any work is spent, run the [fit check](references/fit-and-scope.md): say plainly when the market is not measured or Obi is a weak fit, instead of proceeding. When the customer asks for several audiences, markets or languages, give the [one-scope answer](references/fit-and-scope.md#several-audiences-markets-or-languages). Within that scope, continue routine public reads and checks autonomously. Read [adaptive research](references/host-work.md#adaptive-research-and-tool-recovery) for priority-page understanding, tool discovery, interrupted inventories or a missing search/browser capability.
37
37
 
38
38
  Read `obi harness coverage --json` for the selected case before new Business work. If the account is inactive and `eligibleForFreeActivation` is true, the customer's agreed public-research mission includes activating the new Free plan with `obi harness activate --free --json`; show the actual question and engine coverage returned by that plan. An existing active plan needs no activation. If eligibility is false, keep its historical coverage and explain that an offer migration is separate. Activation does not start a scan. If its response is lost, read `harness coverage` before another activation attempt. When the customer wants to buy or change packs or engines, or evidence shows a gap the plan does not measure, read [purchase](references/purchase.md).
39
39
 
@@ -51,6 +51,8 @@ When the customer reports an implemented action, read [outcome observations](ref
51
51
 
52
52
  Lead with the outcome, material uncertainty and one useful next action. On an interrupted job, reread the same case and stage; report meaningful progress or a blocker, use the server's next poll time when available, and retain the work ID. A timeout on a write calls for a read before any retry. Reuse the same replay key for an identical result; a changed result needs the current work version and a new key.
53
53
 
54
+ When a failure cannot be recovered in this conversation (the same error after the retry its `nextAction` names, or `obi doctor --json` reporting `server` as `degraded` or `unreachable`), say so and offer once to send it to Obi support. On the customer's yes, run `obi support "<what happened, in their words>" --json`: it adds the CLI version, the system and the last error code itself. When it fails with `SUPPORT_NOT_SENT`, nothing was sent: give them support@hireobi.com and the `support.diagnosticText` to paste. Never put a token or a credential file in the message.
55
+
54
56
  Separate site observations, customer confirmations, host inferences and independently measured answers. Request only the private extracts needed for the agreed scope; keep secrets and unrelated workspace material local. A local draft or patch is a reviewable suggestion until the customer accepts it. The Skill runs when invoked; while the harness is closed, saved server measurements can continue but host research and reasoning wait for resumption.
55
57
 
56
58
  Stay in customer mode even inside the Obi source repository. A product-use failure is explained and recovered in this conversation. Open a development task, branch or pull request only after an explicit separate software-development request.
@@ -0,0 +1,32 @@
1
+ # Fit, markets and scope
2
+
3
+ Read before the first research of a business, and when the customer asks for several audiences, markets or languages. Obi says what it cannot measure before any work is spent, and never stretches one scope to cover several.
4
+
5
+ ## Fit check, before any work
6
+
7
+ Judge the fit from the domain, the customer's words and one read of the home page, before activating a plan or starting research. `obi capabilities --json` lists the markets Obi measures in `measurementMarkets`; name them from that list.
8
+
9
+ | What you see | What you say | What you do next |
10
+ |---|---|---|
11
+ | The buyers are in a market outside `measurementMarkets` | "Obi does not measure <market> yet. It measures in: <the names from `measurementMarkets`>." | Ask whether they also sell in a listed market. If yes, offer that market as the scope and say the answers will describe that market only. If no, stop there. Either way, offer to record the request: `obi support "<market>, <domain>" --tag market-request --json`. |
12
+ | The buyers search in a language no listed market uses | The same sentence, naming the language and the markets. | The same two offers. |
13
+ | No AI-search buying journey: buyers choose without asking an assistant (an impulse or habit purchase, a walk-in local trade, a sale won only by tender or by marketplace listing) | "Obi is a weak fit for <business>: <the reason from what you read>. It measures what AI assistants answer to buyers who research before choosing." | Say what would change the verdict (a product line buyers research, a business customer segment). Continue only when the customer names such a segment or still asks for the Free measurement; then say what the scan will not show. |
14
+ | The company has no public pages to read, or blocks them | "Obi reads public pages; it cannot ground questions without them." | Ask for the public domain that carries the offer; without one, stop. |
15
+
16
+ `obi support` sends only from a signed-in machine. When it fails with `SUPPORT_NOT_SENT`, as for a prospect who has no account yet, give them support@hireobi.com and the `support.diagnosticText` to paste.
17
+
18
+ A weak fit is a judgment you state with its evidence, as [business judgment](business-judgment.md) describes; the customer may correct the fact it rests on. Say it once, in one or two sentences, then the one next action. A supported market with a clear buying journey needs no fit statement.
19
+
20
+ When the server answers `MARKET_UNSUPPORTED` to a questions submission, the same rule applies: say it plainly, name the markets, offer the request.
21
+
22
+ ## Several audiences, markets or languages
23
+
24
+ One scope is one market, one primary audience and one measured language: the language buyers of that market use. An account holds one scope; packs add questions inside it. There is no second scope on an account today, so it has no price.
25
+
26
+ When the customer asks for more (for example three ICPs, or three languages), answer in this order:
27
+
28
+ 1. The rule, in one sentence: "Obi measures one scope per account: one market, one primary audience, in that market's language. More packs add questions for that scope; they do not add a second one."
29
+ 2. Your recommendation of the primary scope, with its reason from the evidence: the segment the pricing page, customer stories and hiring serve, or the market where the measured rivals compete. Recommend one; ask only for confirmation.
30
+ 3. What still covers the others inside one scope: a secondary segment stays in the business review, and the three to five buyer profiles of the [cold and context pairs](questions.md#buyer-profiles-and-the-coldcontext-pairs) vary the personas of the primary audience.
31
+ 4. The limit, plainly: "A second market, audience or language on the same account is not available yet." Offer to record the request: `obi support "<the scopes wanted>" --tag scope-request --json`.
32
+ 5. Save the confirmed choice: `obi memory add --kind decision --text "<their words>" --json`.
@@ -1,6 +1,6 @@
1
1
  # Buying paid packs
2
2
 
3
- Read when the customer wants to buy packs or change the packs or engines of a paid plan, and before proposing a change yourself. Buying happens in this conversation, where the customer reviews the questions each pack will measure; the `/billing` page shows the plan and keeps cards, invoices and cancellation. Every price you show comes from the server's quote response.
3
+ Read when the customer wants to buy packs or change the packs or engines of a paid plan, and before proposing a change yourself. Buying happens in this conversation, where the customer reviews the questions each pack will measure; the `/billing` page shows the plan and keeps cards, invoices and cancellation. Every amount the customer agrees to pay comes from the server's quote or preview response.
4
4
 
5
5
  Only the owner of the connected business account buys, with a verified email; a public-company evaluation case cannot.
6
6
 
@@ -70,7 +70,38 @@ A change that adds and removes at once (`PLAN_CHANGE_TWO_STEPS`) runs in two ste
70
70
 
71
71
  ## Proposing a change yourself
72
72
 
73
- When evidence shows a gap the plan does not measure, such as a buying situation outside the reviewed questions or a rival cited on questions the plan leaves out, propose the change once, with that evidence and the price from a quote or preview. After a no, raise it again only on new evidence.
73
+ Propose a change only when a measurement shows a gap the plan does not cover. Each trigger needs its evidence from the saved results (`obi questions results --json`, `obi harness evidence`), named by question and date:
74
+
75
+ | Trigger | Measured evidence it needs | What you propose |
76
+ |---|---|---|
77
+ | Perplexity | On research-style buying questions (comparisons, alternatives, "which should we use"), the scan's answers name rivals with cited sources while the company is absent or uncited. | Perplexity on those questions, 1 to 10 per pack. |
78
+ | Claude | The confirmed primary audience is technical or B2B, and on the same measured buying questions the engines already disagree on the brands they name. Where Claude is already measured on some questions, its answers differ from the other engines on them. | Claude on those buying questions, 1 to 10 per pack. |
79
+ | More packs | Buying situations in the evidence (a confirmed segment's situations, a rival's angle, the sources the answers cite) that no reviewed question covers, while every current question covers a distinct one. | One more pack, with the situations it would add. |
80
+ | Paid from Free | Two monthly scans differ (a rival enters or leaves the answers, the company's presence changes) and nothing dates the change inside the month, or the customer reported an action whose effect the next monthly scan would show weeks late. | The first pack, for its weekly scan. |
81
+
82
+ The host's own answer to a question is never this evidence, and an engine the plan does not measure has no measured answers to cite.
83
+
84
+ A proposal is one sentence of evidence, the price, one question. Cite the server's price whenever a quote is possible; both commands below charge nothing:
85
+
86
+ | Plan and change | Price to cite |
87
+ |---|---|
88
+ | A paid plan, adding an engine on questions it already measures | Write the quote file of the new plan and run `obi harness purchase change --input <file> --json` ([changing a paid plan](#changing-a-paid-plan), step 2): cite `amountDueCents` now and the new `quote.monthlyAmountCents`. |
89
+ | No paid plan, with a reviewed set of 25 questions per pack | `obi harness purchase quote --input <file> --json`: cite `monthlyAmountCents`. |
90
+ | The change needs questions not reviewed yet (one more pack, a first pack without a reviewed set) | No quote is possible yet: cite the list price below, labelled "list price", and offer to prepare the questions and the quote. |
91
+
92
+ The list price is what the change adds each month, computed from `obi capabilities --json` (`catalog.monthlyCents`) and the plan in `obi harness coverage --json`:
93
+
94
+ | Change | Monthly amount it adds |
95
+ |---|---|
96
+ | An engine on a paid plan | Its per-pack amount times the packs of the plan (`plan.questionCount` divided by 25). |
97
+ | One more pack | `corePack`, plus the per-pack amount of each engine already on the plan (an engine whose `plan.engineQuestionCounts` is above zero). |
98
+ | The first pack from Free | `corePack`. |
99
+
100
+ A list price is before any discount on the account; the quote or preview confirms the exact amount before any yes to pay. For example: "On 6 of your 10 comparison questions, the 3 November scan cites <rival> with sources and never you. Perplexity on those questions adds <monthlyAmountCents difference> a month, <amountDueCents> today. Shall I apply it?"
101
+
102
+ - Propose once per gap, and at most one proposal in a reply. After a no, save it (`obi memory add --kind decision --text "<their words>" --json`) and raise that gap again only on new evidence: a later scan, a reported outcome, a corrected business fact.
103
+ - Optional engines are never filled to quota: propose the buying questions the evidence names, however few.
104
+ - No proposal while a failure is unresolved, before the first results exist, or as the answer to a question the customer asked about something else.
74
105
 
75
106
  ## What waits for the customer
76
107
 
@@ -1,6 +1,6 @@
1
1
  # Buyer questions, scan and priorities
2
2
 
3
- After the business review is confirmed, inspect `obi harness show --stage questions --json`. Show the supported measurement markets and confirmed buyer audiences from its work context. Ask the customer to choose one market and one primary audience; a suggested market is guidance, not consent or a default. Prepare the displayed coverage count in the host and save it with the versioned `harness submit --stage questions --input <file> --json` path described in [host work](host-work.md). `obi questions show --json` reads the accepted set and its preparation state. A queued or running state means wait and inspect again after meaningful progress; do not announce that questions already exist.
3
+ After the business review is confirmed, inspect `obi harness show --stage questions --json`. Show the supported measurement markets and confirmed buyer audiences from its work context. Ask the customer to choose one market and one primary audience; a suggested market is guidance, not consent or a default. When they ask for several, give the [one-scope answer](fit-and-scope.md#several-audiences-markets-or-languages). Prepare the displayed coverage count in the host and save it with the versioned `harness submit --stage questions --input <file> --json` path described in [host work](host-work.md). `obi questions show --json` reads the accepted set and its preparation state. A queued or running state means wait and inspect again after meaningful progress; do not announce that questions already exist.
4
4
 
5
5
  ## Writing the questions
6
6