@harnessforce/cli 0.3.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +39 -20
  2. package/dist/bin.js +680 -43
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  `harnessforce`, the command line of [Harnessforce](https://github.com/lambda-script/harnessforce-agent). It
4
4
  connects a machine to a Harnessforce Workspace, supplies the ingest key to Claude Code's OpenTelemetry
5
- exporter, launches an agent linked to an Issue, and imports the metadata of past Claude Code sessions.
5
+ exporter, launches an agent linked to an Issue, and imports the metadata of past Claude Code and Codex sessions.
6
6
 
7
7
  It is used together with the `harnessforce` Claude Code plugin, whose `/harnessforce:setup` installs
8
- it. Public distribution on npm starts once the production domain of Harnessforce is decided; until
9
- then, install it from the [local marketplace build](https://github.com/lambda-script/harnessforce-agent/blob/main/docs/runbooks/local-marketplace.md).
8
+ it. It is published on npm. To connect to a staging or local Harnessforce instead, install it from the
9
+ [local marketplace build](https://github.com/lambda-script/harnessforce-agent/blob/main/docs/runbooks/local-marketplace.md).
10
10
 
11
11
  ## Requirements
12
12
 
@@ -30,8 +30,11 @@ harnessforce init
30
30
  | `harnessforce init [--port <port>] [--send-content] [--no-plugin-auto-update]` | Logs in with the browser, stores a user ingest key and an API token in the keychain, and configures Claude Code and Codex |
31
31
  | `harnessforce otel-headers` | Prints the `Authorization` header for Claude Code's `otelHeadersHelper`. Exits 3 when there is no key, 4 when the destination differs from the pinned origin, 5 while the CLI version is unsupported, and 1 on other failures. On Linux, after printing a key read from the kernel keyring, it moves that Workspace's items to an unlocked Secret Service (not when the hook starts it), so a reboot does not lose them |
32
32
  | `harnessforce run --issue <identifier> -- <agent> [args]` | Launches an agent with its session linked to an Issue |
33
- | `harnessforce import` | Sends the metadata of past Claude Code sessions in connected repositories |
33
+ | `harnessforce import` | Sends the metadata of past Claude Code and Codex sessions in connected repositories |
34
+ | `harnessforce status [--json]` | Shows the connected Workspace, your role, the setup steps that remain with their URLs, and whether the `origin` repository of the current directory is connected to the GitHub App. Reads only; the remote URL is compared on this machine and never sent. Exits 0 whenever the status was read |
34
35
  | `harnessforce tune [--all] [--no-send] [--show-report] [--json]` | Analyzes past sessions on this machine by public rules and sends the counts (analysis reports) |
36
+ | `harnessforce tune --report-only [--all]` | Analyzes and sends like `harnessforce tune`, but writes nothing to stdout. Meant for your OS scheduler |
37
+ | `harnessforce tune --print-schedule` | Prints one cron line that runs `harnessforce tune --report-only` every Monday at 9:00 local time. Changes nothing and makes no network request |
35
38
  | `harnessforce tune record` / `harnessforce tune --purge` | Records a proposal from stdin / deletes everything under `~/.harnessforce/tune/` |
36
39
  | `harnessforce top [--once] [--json] [--ascii] [--theme <auto\|light\|dark\|ansi>]` | Shows the Claude Code and Codex sessions on this machine in the terminal: state, tokens, context and tool failures. Reads only local files and never connects to Harnessforce |
37
40
  | `harnessforce hook session-start` | The entry of Codex's `SessionStart` hook: reads `session_id`, `cwd` and `source` from stdin, registers the session, and prints the session ID for the agent's context. Always exits 0 |
@@ -114,7 +117,9 @@ tokens are sent only to the origins pinned by `harnessforce init`.
114
117
  1. It checks the OS keychain first: macOS Keychain or the Windows Credential Manager (through
115
118
  [`@napi-rs/keyring`](https://github.com/Brooooooklyn/keyring-node)). On Linux it writes and deletes
116
119
  a non-secret `availability-probe` item in the Secret Service (GNOME Keyring or KWallet), waiting at
117
- most 60 seconds for you to unlock it, and saves there when that works. Otherwise, as on a machine
120
+ most 60 seconds for you to unlock it, and saves there when that works. With KWallet it also says
121
+ which window asks for the password: KWallet shows one unlock window at a time, so an earlier
122
+ unanswered window behind other windows keeps the new one from appearing. Otherwise, as on a machine
118
123
  reached only over SSH, it saves to the kernel's persistent keyring, which lives in memory and is
119
124
  cleared on reboot or after a period of disuse; run `harnessforce init` again then. Without either
120
125
  store it stops. Keys are never written to a plain file.
@@ -134,7 +139,9 @@ tokens are sent only to the origins pinned by `harnessforce init`.
134
139
  them last, so a failure part way never pairs an old origin with a new key or token. After saving to
135
140
  the Secret Service it deletes the same Workspace's items from the kernel keyring. Later commands read
136
141
  a Workspace from the kernel keyring when it holds the ingest key there, and from the Secret Service
137
- otherwise.
142
+ otherwise. Only `harnessforce init`, `run` and `import` ask a locked Secret Service for an unlock;
143
+ hooks and `harnessforce otel-headers` check the lock over D-Bus first and treat a locked item as
144
+ missing.
138
145
  5. It updates the Claude Code user settings (`~/.claude/settings.json`, or `$CLAUDE_CONFIG_DIR`),
139
146
  changing only these keys:
140
147
  - `env`: the OTel exporter variables, `HARNESSFORCE_URL`, `HARNESSFORCE_ENDPOINT` and
@@ -150,7 +157,7 @@ tokens are sent only to the origins pinned by `harnessforce init`.
150
157
  - `[otel].exporter`: OTLP/HTTP (`protocol = "binary"`) to the ingest endpoint with
151
158
  `Authorization = "Bearer <user ingest key>"`. Codex cannot take the header from a command or expand
152
159
  `${VAR}`, so this is the one place a key is written to a plain file. It is the user ingest key only
153
- (never the API token), and you can revoke it on the Workspace's send keys page. `log_user_prompt` and
160
+ (never the API token), and you can revoke it in My ingest keys on your account page. `log_user_prompt` and
154
161
  `log_agent_responses` are set to `true` for a category you send with `--send-content` and the
155
162
  Workspace stores, and `false` otherwise
156
163
  - `[[hooks.SessionStart]]`: a handler that runs `harnessforce hook session-start`
@@ -167,12 +174,13 @@ tokens are sent only to the origins pinned by `harnessforce init`.
167
174
  `harnessforce otel-headers` prints `{"Authorization":"Bearer <key>"}`. The key is `HARNESSFORCE_INGEST_KEY` from
168
175
  the managed settings file when that is set, and otherwise the user key for
169
176
  `HARNESSFORCE_WORKSPACE_ID` from the keychain. If
170
- there is no key, it prints nothing and exits 1. The user key is printed only when every destination
177
+ there is no key, it prints nothing and exits 3. The user key is printed only when every destination
171
178
  variable that is set (`HARNESSFORCE_ENDPOINT`, `OTEL_EXPORTER_OTLP_ENDPOINT` and the per-signal
172
179
  `OTEL_EXPORTER_OTLP_*_ENDPOINT`) has the pinned origin. This stops a repository's
173
180
  `.claude/settings.json` `env` from pointing the hook at another host to collect the key. Otherwise it
174
- writes `harnessforce: user key withheld (destination not verified)` to stderr and exits 1. Run
175
- `harnessforce init` again to re-pin.
181
+ writes `harnessforce: user key withheld (destination not verified)` to stderr and exits 4. Run
182
+ `harnessforce init` again to re-pin. While the CLI version is unsupported it exits 5 without reading
183
+ the keychain, and any other failure (such as an unavailable keychain) exits 1.
176
184
 
177
185
  ### Refreshing the API token
178
186
 
@@ -239,15 +247,23 @@ It exits with the agent's exit code. Prompt and body logging are never turned on
239
247
 
240
248
  ## Importing past sessions (`harnessforce import`)
241
249
 
242
- `harnessforce import` sends metadata of past Claude Code sessions on this machine to Harnessforce, so that
243
- work done before the plugin was installed can be linked to issues too. Run `harnessforce init` first.
244
-
245
- - It reads the transcripts in `~/.claude/projects/<project>/<session>.jsonl` (or
246
- `$CLAUDE_CONFIG_DIR/projects`). Their format is not documented, so the parser is versioned
247
- (`parser_version`), and unreadable lines and files are skipped and counted.
248
- - Only these values are sent: session ID, first prompt ID, start and end time, repository, branch,
249
- the most used model, input and output token counts (without cache tokens), and the number of calls
250
- and failures per tool. Prompts, responses and tool inputs and outputs are never sent.
250
+ `harnessforce import` sends metadata of past Claude Code and Codex sessions on this machine to Harnessforce,
251
+ so that work done before the plugin was installed can be linked to issues too. Run `harnessforce init` first.
252
+
253
+ - For Claude Code it reads the transcripts in `~/.claude/projects/<project>/<session>.jsonl`, or under
254
+ the `CLAUDE_CONFIG_DIR` set in the `env` of the managed settings file or of `~/.claude/settings.json`.
255
+ The `CLAUDE_CONFIG_DIR` of the process environment is not read, because a repository's
256
+ `.claude/settings.json` can change it.
257
+ - For Codex it reads the rollouts in `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`. `CODEX_HOME` is not
258
+ read, for the same reason. Compressed rollouts (`.jsonl.zst`) and `archived_sessions` are not read; the
259
+ compressed ones in the import window are counted and reported.
260
+ - The formats are not documented, so the parsers are versioned (`parser_version`), and unreadable lines
261
+ and files are skipped and counted.
262
+ - Only these values are sent: agent, session ID, start and end time, repository, branch, the most used
263
+ model, input and output token counts (without cache tokens), and the number of calls per tool. Claude
264
+ Code sessions also send the first prompt ID and the number of failures per tool. Codex sessions also
265
+ send the commit the session started on, and no failure counts, because a rollout does not keep whether
266
+ every call succeeded. Prompts, responses and tool inputs and outputs are never sent.
251
267
  - Only sessions whose `cwd` is in a repository connected to the Workspace are sent. The connected
252
268
  repositories come from `GET <HARNESSFORCE_URL>/api/v1/repositories` (all pages) and the import
253
269
  window from `session_import_days` (`GET <HARNESSFORCE_URL>/api/v1/workspace`), both with the API
@@ -283,7 +299,7 @@ role cannot use it.
283
299
  commands, paths and error text never leave the machine.
284
300
  - By default it analyzes sessions started in the last 30 days in connected repositories. `--all`
285
301
  analyzes every session, but still sends only sessions in connected repositories that started within
286
- `session_import_days`. Sessions without a Run are imported first, like `harnessforce import`.
302
+ `session_import_days`. Sessions that were not imported yet are imported first, like `harnessforce import`.
287
303
  - Sending is fail-open: when Harnessforce cannot be reached, the reports stay in
288
304
  `~/.harnessforce/tune/unsent.json` and are sent by the next run. `401` and `403` delete the unsent
289
305
  reports and ask you to run `harnessforce init`.
@@ -295,6 +311,9 @@ role cannot use it.
295
311
  - `--json` also lists, for each recorded proposal whose application was detected, the value of its
296
312
  category in the 14 days before and after the detection time (`followups`). The difference is not
297
313
  a cause. Once a `--json` run outputs a follow-up whose after period has ended, later runs omit it.
314
+ - To send weekly without opening Claude Code, add the line that `harnessforce tune --print-schedule`
315
+ prints to your crontab yourself. Harnessforce never registers it. To stop, remove the line or set
316
+ `send_report` to `false`.
298
317
  - Everything it keeps is under `~/.harnessforce/tune/`; `harnessforce tune --purge` deletes it.
299
318
 
300
319
  ## License
package/dist/bin.js CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
+ import { access, chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, utimes, writeFile } from "node:fs/promises";
3
4
  import { constants, homedir, tmpdir } from "node:os";
4
5
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
5
6
  import { basename, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
6
7
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
7
- import { access, chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, stat, utimes, writeFile } from "node:fs/promises";
8
8
  import { execFile, spawn } from "node:child_process";
9
9
  import { constants as constants$1, createReadStream } from "node:fs";
10
+ import { connect } from "node:net";
10
11
  import { createServer } from "node:http";
11
12
  import { ATTR, INTERVENTION_KINDS, LOOP_KINDS, MAX_WORK_LOCATIONS, PROPOSAL_KINDS, isToken, normalizeRepository } from "@harnessforce/semconv";
12
13
  import { createInterface } from "node:readline";
@@ -434,6 +435,7 @@ const INIT_MESSAGES = {
434
435
  contentNotStored: "This workspace does not store content, so sending content was not enabled. An owner or admin can turn it on under Collection & retention in the workspace settings, then run `harnessforce init --send-content` again",
435
436
  contentNotSent: "Content is not sent from this machine. Run `harnessforce init --send-content` to send it"
436
437
  };
438
+ const kwalletUnlockHint = (walletLabel = "kdewallet") => `KWallet asks for the password of the wallet "${walletLabel}" in a "KDE Wallet Service" window. If no window appears, an earlier request may be waiting behind other windows: answer that window, or log out and back in, then run \`harnessforce init\` again`;
437
439
  const codexNotes = (configPath) => [
438
440
  `Wrote the Codex configuration (${configPath}). Restart Codex to apply it`,
439
441
  "Codex hooks do not run until you trust them in Codex's /hooks. Trust the harnessforce SessionStart, PostToolUse, and Stop hooks in /hooks",
@@ -479,6 +481,7 @@ const apiTokenWorkspaceId = (account) => account.endsWith(API_TOKEN_SUFFIX) ? ac
479
481
  // src/credentials/linux-keychain.ts
480
482
  const SECRET_SERVICE_TIMEOUT_MS = 6e4;
481
483
  const WAITING_NOTICE_DELAY_MS = 2e3;
484
+ const PROVIDER_TIMEOUT_MS = 500;
482
485
  const PROBE_ACCOUNT$1 = "availability-probe";
483
486
  const PROBE_VALUE = "harnessforce";
484
487
  const MIGRATION_PROBE_TIMEOUT_MS = 200;
@@ -487,10 +490,11 @@ const CLEANUP_TIMEOUT_MS = 200;
487
490
  const succeeds = (operation) => operation().then(() => true, () => false);
488
491
  const workspaceOf = (account) => account.slice(0, account.lastIndexOf(":"));
489
492
  function createLinuxKeychain(options) {
490
- return build(options, SECRET_SERVICE_TIMEOUT_MS);
493
+ return build(options, SECRET_SERVICE_TIMEOUT_MS, true);
491
494
  }
492
- function build(options, timeoutMs) {
493
- const { kernel, secretService } = options;
495
+ function build(options, timeoutMs, allowsUnlockPrompt) {
496
+ const { kernel } = options;
497
+ const secretService = allowsUnlockPrompt ? options.secretService : options.secretService.withoutUnlockPrompt();
494
498
  const locations = /* @__PURE__ */ new Map();
495
499
  const locate = (workspaceId) => {
496
500
  let located = locations.get(workspaceId);
@@ -535,7 +539,7 @@ function build(options, timeoutMs) {
535
539
  if (location !== "kernel") return;
536
540
  await migrate(options, workspaceId).catch(() => {});
537
541
  },
538
- withTimeout: (ms) => build(options, ms),
542
+ withTimeout: (ms) => build(options, ms, false),
539
543
  openForInit: (init) => openForInit(options, timeoutMs, init)
540
544
  };
541
545
  }
@@ -544,8 +548,17 @@ async function openForInit({ kernel, secretService }, timeoutMs, { previousWorks
544
548
  await store.set(PROBE_ACCOUNT$1, PROBE_VALUE, timeoutMs);
545
549
  await store.delete(PROBE_ACCOUNT$1, timeoutMs);
546
550
  });
547
- const notice = setTimeout(onWaiting, WAITING_NOTICE_DELAY_MS);
548
- const usesSecretService = await canWrite(secretService).finally(() => clearTimeout(notice));
551
+ let isWaiting = true;
552
+ const notify = (provider) => {
553
+ if (isWaiting) onWaiting(provider);
554
+ };
555
+ const notice = setTimeout(() => {
556
+ secretService.provider(PROVIDER_TIMEOUT_MS).then(notify, () => notify(void 0));
557
+ }, WAITING_NOTICE_DELAY_MS);
558
+ const usesSecretService = await canWrite(secretService).finally(() => {
559
+ isWaiting = false;
560
+ clearTimeout(notice);
561
+ });
549
562
  const readKernel = async () => {
550
563
  if (!previousWorkspaceId) return [];
551
564
  const accounts = [ingestKeyAccount(previousWorkspaceId), apiTokenAccount(previousWorkspaceId)];
@@ -584,7 +597,8 @@ const MIGRATION_ORDER = (workspaceId) => [
584
597
  apiTokenAccount(workspaceId),
585
598
  ingestKeyAccount(workspaceId)
586
599
  ];
587
- async function migrate({ kernel, secretService }, workspaceId) {
600
+ async function migrate({ kernel, secretService: unlimited }, workspaceId) {
601
+ const secretService = unlimited.withoutUnlockPrompt();
588
602
  let isExpired = false;
589
603
  const deadline = Date.now() + MIGRATION_BUDGET_MS;
590
604
  const remaining = () => {
@@ -712,6 +726,419 @@ const unsupported = {
712
726
  withTimeout: () => unsupported,
713
727
  openForInit: async () => void 0
714
728
  };
729
+ // src/credentials/dbus.ts
730
+ const TYPES = [
731
+ "",
732
+ "method_call",
733
+ "method_return",
734
+ "error",
735
+ "signal"
736
+ ];
737
+ const FIELDS = {
738
+ path: [1, "o"],
739
+ interface: [2, "s"],
740
+ member: [3, "s"],
741
+ errorName: [4, "s"],
742
+ replySerial: [5, "u"],
743
+ destination: [6, "s"],
744
+ sender: [7, "s"],
745
+ signature: [8, "g"]
746
+ };
747
+ function parseSignature(signature) {
748
+ let index = 0;
749
+ const one = () => {
750
+ const code = signature[index++] ?? "";
751
+ if (code === "a") return {
752
+ code,
753
+ children: [one()]
754
+ };
755
+ if (code === "(" || code === "{") {
756
+ const close = code === "(" ? ")" : "}";
757
+ const children = [];
758
+ while (signature[index] !== close) {
759
+ if (index >= signature.length) throw new Error("bad signature");
760
+ children.push(one());
761
+ }
762
+ index++;
763
+ return {
764
+ code,
765
+ children
766
+ };
767
+ }
768
+ if (!"ybusogv".includes(code) || code === "") throw new Error(`unsupported D-Bus type ${code}`);
769
+ return {
770
+ code,
771
+ children: []
772
+ };
773
+ };
774
+ const types = [];
775
+ while (index < signature.length) types.push(one());
776
+ return types;
777
+ }
778
+ const alignmentOf = (type) => ({
779
+ y: 1,
780
+ g: 1,
781
+ v: 1,
782
+ b: 4,
783
+ u: 4,
784
+ s: 4,
785
+ o: 4,
786
+ a: 4
787
+ })[type.code] ?? 8;
788
+ var Writer = class {
789
+ bytes = [];
790
+ get length() {
791
+ return this.bytes.length;
792
+ }
793
+ align(n) {
794
+ while (this.bytes.length % n !== 0) this.bytes.push(0);
795
+ }
796
+ uint32(value) {
797
+ this.align(4);
798
+ const buffer = Buffer.alloc(4);
799
+ buffer.writeUInt32LE(value);
800
+ this.bytes.push(...buffer);
801
+ }
802
+ patchUint32(offset, value) {
803
+ const buffer = Buffer.alloc(4);
804
+ buffer.writeUInt32LE(value);
805
+ this.bytes.splice(offset, 4, ...buffer);
806
+ }
807
+ raw(values) {
808
+ this.bytes.push(...values);
809
+ }
810
+ write(type, value) {
811
+ const [child] = type.children;
812
+ switch (type.code) {
813
+ case "y":
814
+ this.raw([Number(value)]);
815
+ return;
816
+ case "b":
817
+ this.uint32(value ? 1 : 0);
818
+ return;
819
+ case "u":
820
+ this.uint32(Number(value));
821
+ return;
822
+ case "s":
823
+ case "o": {
824
+ const text = Buffer.from(String(value), "utf8");
825
+ this.uint32(text.length);
826
+ this.raw([...text, 0]);
827
+ return;
828
+ }
829
+ case "g": {
830
+ const text = Buffer.from(String(value), "utf8");
831
+ this.raw([
832
+ text.length,
833
+ ...text,
834
+ 0
835
+ ]);
836
+ return;
837
+ }
838
+ case "v": {
839
+ const variant = value;
840
+ this.write({
841
+ code: "g",
842
+ children: []
843
+ }, variant.signature);
844
+ const [inner] = parseSignature(variant.signature);
845
+ if (!inner) throw new Error("empty variant");
846
+ this.write(inner, variant.value);
847
+ return;
848
+ }
849
+ case "a": {
850
+ if (!child) throw new Error("bad array");
851
+ this.uint32(0);
852
+ const lengthAt = this.length - 4;
853
+ this.align(alignmentOf(child));
854
+ const start = this.length;
855
+ const elements = child.code === "{" ? Object.entries(value) : value;
856
+ for (const element of elements) this.write(child, element);
857
+ this.patchUint32(lengthAt, this.length - start);
858
+ return;
859
+ }
860
+ default: {
861
+ this.align(8);
862
+ const fields = value;
863
+ type.children.forEach((field, i) => {
864
+ this.write(field, fields[i]);
865
+ });
866
+ }
867
+ }
868
+ }
869
+ toBuffer() {
870
+ return Buffer.from(this.bytes);
871
+ }
872
+ };
873
+ var Reader = class {
874
+ offset = 0;
875
+ buffer;
876
+ isLittleEndian;
877
+ constructor(buffer, isLittleEndian) {
878
+ this.buffer = buffer;
879
+ this.isLittleEndian = isLittleEndian;
880
+ }
881
+ align(n) {
882
+ this.offset = Math.ceil(this.offset / n) * n;
883
+ }
884
+ uint32() {
885
+ this.align(4);
886
+ const value = this.isLittleEndian ? this.buffer.readUInt32LE(this.offset) : this.buffer.readUInt32BE(this.offset);
887
+ this.offset += 4;
888
+ return value;
889
+ }
890
+ read(type) {
891
+ const [child] = type.children;
892
+ switch (type.code) {
893
+ case "y": return this.buffer[this.offset++];
894
+ case "b": return this.uint32() !== 0;
895
+ case "u": return this.uint32();
896
+ case "s":
897
+ case "o": {
898
+ const length = this.uint32();
899
+ const text = this.buffer.toString("utf8", this.offset, this.offset + length);
900
+ this.offset += length + 1;
901
+ return text;
902
+ }
903
+ case "g": {
904
+ const length = this.buffer[this.offset++] ?? 0;
905
+ const text = this.buffer.toString("utf8", this.offset, this.offset + length);
906
+ this.offset += length + 1;
907
+ return text;
908
+ }
909
+ case "v": {
910
+ const signature = this.read({
911
+ code: "g",
912
+ children: []
913
+ });
914
+ const [inner] = parseSignature(signature);
915
+ if (!inner) throw new Error("empty variant");
916
+ return {
917
+ signature,
918
+ value: this.read(inner)
919
+ };
920
+ }
921
+ case "a": {
922
+ if (!child) throw new Error("bad array");
923
+ const length = this.uint32();
924
+ this.align(alignmentOf(child));
925
+ const end = this.offset + length;
926
+ const elements = [];
927
+ while (this.offset < end) elements.push(this.read(child));
928
+ return child.code === "{" ? Object.fromEntries(elements) : elements;
929
+ }
930
+ default:
931
+ this.align(8);
932
+ return type.children.map((field) => this.read(field));
933
+ }
934
+ }
935
+ };
936
+ const HEADER_FIELDS = parseSignature("a(yv)")[0];
937
+ function encodeMessage(message) {
938
+ const signature = message.signature ?? "";
939
+ const bodyWriter = new Writer();
940
+ parseSignature(signature).forEach((type, i) => {
941
+ bodyWriter.write(type, message.body?.[i]);
942
+ });
943
+ const body = bodyWriter.toBuffer();
944
+ const fields = [];
945
+ for (const [name, [code, fieldType]] of Object.entries(FIELDS)) {
946
+ const value = message[name];
947
+ if (value === void 0 || name === "signature" && value === "") continue;
948
+ fields.push([code, {
949
+ signature: fieldType,
950
+ value
951
+ }]);
952
+ }
953
+ const header = new Writer();
954
+ header.raw([
955
+ "l".charCodeAt(0),
956
+ TYPES.indexOf(message.type),
957
+ 0,
958
+ 1
959
+ ]);
960
+ header.uint32(body.length);
961
+ header.uint32(message.serial);
962
+ header.write(HEADER_FIELDS, fields);
963
+ header.align(8);
964
+ return Buffer.concat([header.toBuffer(), body]);
965
+ }
966
+ function decodeMessages(buffer) {
967
+ const messages = [];
968
+ let rest = buffer;
969
+ while (rest.length >= 16) {
970
+ const isLittleEndian = rest[0] === "l".charCodeAt(0);
971
+ const readUint32 = (offset) => isLittleEndian ? rest.readUInt32LE(offset) : rest.readUInt32BE(offset);
972
+ const bodyLength = readUint32(4);
973
+ const fieldsLength = readUint32(12);
974
+ const headerLength = Math.ceil((16 + fieldsLength) / 8) * 8;
975
+ const total = headerLength + bodyLength;
976
+ if (rest.length < total) break;
977
+ const reader = new Reader(rest.subarray(0, total), isLittleEndian);
978
+ reader.offset = 8;
979
+ const serial = reader.uint32();
980
+ const fields = reader.read(HEADER_FIELDS);
981
+ const message = {
982
+ type: TYPES[rest[1] ?? 0] || "signal",
983
+ serial
984
+ };
985
+ for (const [code, { value }] of fields) {
986
+ const name = Object.entries(FIELDS).find(([, [c]]) => c === code)?.[0];
987
+ if (name) Object.assign(message, { [name]: value });
988
+ }
989
+ reader.offset = headerLength;
990
+ message.body = parseSignature(message.signature ?? "").map((type) => reader.read(type));
991
+ messages.push(message);
992
+ rest = rest.subarray(total);
993
+ }
994
+ return {
995
+ messages,
996
+ rest
997
+ };
998
+ }
999
+ function socketPath(address) {
1000
+ for (const entry of address.split(";")) {
1001
+ const [transport, rest = ""] = entry.split(/:(.*)/s);
1002
+ if (transport !== "unix") continue;
1003
+ const params = new Map(rest.split(",").map((pair) => pair.split(/=(.*)/s)));
1004
+ const path = params.get("path");
1005
+ if (path) return decodeURIComponent(path);
1006
+ const abstract = params.get("abstract");
1007
+ if (abstract) return `\0${decodeURIComponent(abstract)}`;
1008
+ }
1009
+ }
1010
+ function openBus(address, uid, timeoutMs) {
1011
+ const path = socketPath(address);
1012
+ if (!path) return Promise.reject(/* @__PURE__ */ new Error("unsupported D-Bus address"));
1013
+ return new Promise((resolve, reject) => {
1014
+ const socket = connect({ path });
1015
+ const waiting = /* @__PURE__ */ new Map();
1016
+ let serial = 0;
1017
+ let pending = Buffer.alloc(0);
1018
+ let isAuthenticated = false;
1019
+ const fail = (error) => {
1020
+ socket.destroy();
1021
+ reject(error);
1022
+ for (const call of waiting.values()) call.reject(error);
1023
+ waiting.clear();
1024
+ };
1025
+ const timer = setTimeout(() => fail(/* @__PURE__ */ new Error("D-Bus timed out")), timeoutMs);
1026
+ const close = () => {
1027
+ clearTimeout(timer);
1028
+ socket.destroy();
1029
+ };
1030
+ const call = (request) => new Promise((resolveCall, rejectCall) => {
1031
+ serial += 1;
1032
+ waiting.set(serial, {
1033
+ resolve: resolveCall,
1034
+ reject: rejectCall
1035
+ });
1036
+ socket.write(encodeMessage({
1037
+ type: "method_call",
1038
+ serial,
1039
+ ...request
1040
+ }));
1041
+ });
1042
+ socket.on("error", fail);
1043
+ socket.on("close", () => fail(/* @__PURE__ */ new Error("D-Bus connection closed")));
1044
+ socket.on("connect", () => {
1045
+ const uidHex = Buffer.from(String(uid)).toString("hex");
1046
+ socket.write(`\0AUTH EXTERNAL ${uidHex}\r\n`);
1047
+ });
1048
+ socket.on("data", (chunk) => {
1049
+ pending = Buffer.concat([pending, chunk]);
1050
+ if (!isAuthenticated) {
1051
+ const lineEnd = pending.indexOf("\r\n");
1052
+ if (lineEnd < 0) return;
1053
+ const line = pending.toString("latin1", 0, lineEnd);
1054
+ pending = pending.subarray(lineEnd + 2);
1055
+ if (!line.startsWith("OK ")) return fail(/* @__PURE__ */ new Error("D-Bus authentication failed"));
1056
+ isAuthenticated = true;
1057
+ socket.write("BEGIN\r\n");
1058
+ call({
1059
+ destination: "org.freedesktop.DBus",
1060
+ path: "/org/freedesktop/DBus",
1061
+ interface: "org.freedesktop.DBus",
1062
+ member: "Hello"
1063
+ }).then(() => resolve({
1064
+ call,
1065
+ close
1066
+ }), fail);
1067
+ }
1068
+ const { messages, rest } = decodeMessages(pending);
1069
+ pending = rest;
1070
+ for (const message of messages) {
1071
+ const replied = message.replySerial === void 0 ? void 0 : waiting.get(message.replySerial);
1072
+ if (!replied || message.replySerial === void 0) continue;
1073
+ waiting.delete(message.replySerial);
1074
+ if (message.type === "error") replied.reject(new Error(message.errorName ?? "D-Bus error"));
1075
+ else replied.resolve(message.body ?? []);
1076
+ }
1077
+ });
1078
+ });
1079
+ }
1080
+ // src/credentials/secret-service-lock.ts
1081
+ const SECRETS = "org.freedesktop.secrets";
1082
+ const SERVICE_PATH = "/org/freedesktop/secrets";
1083
+ const DEFAULT_COLLECTION = "/org/freedesktop/secrets/aliases/default";
1084
+ const KWALLET_PROCESS = /^(ksecretd|kwalletd\d*)$/;
1085
+ function sessionBusAddress(env) {
1086
+ if (env.DBUS_SESSION_BUS_ADDRESS) return env.DBUS_SESSION_BUS_ADDRESS;
1087
+ if (env.XDG_RUNTIME_DIR) return `unix:path=${env.XDG_RUNTIME_DIR}/bus`;
1088
+ }
1089
+ const property = (bus, name) => bus.call({
1090
+ destination: SECRETS,
1091
+ path: DEFAULT_COLLECTION,
1092
+ interface: "org.freedesktop.DBus.Properties",
1093
+ member: "Get",
1094
+ signature: "ss",
1095
+ body: ["org.freedesktop.Secret.Collection", name]
1096
+ }).then(([variant]) => variant.value);
1097
+ function createSecretServiceLock({ address, uid, readComm }) {
1098
+ const withBus = async (timeoutMs, use) => {
1099
+ if (!address) throw new Error("no D-Bus session bus");
1100
+ const bus = await openBus(address, uid, timeoutMs);
1101
+ try {
1102
+ return await use(bus);
1103
+ } finally {
1104
+ bus.close();
1105
+ }
1106
+ };
1107
+ return {
1108
+ needsUnlock: (operation, account, timeoutMs) => withBus(timeoutMs, async (bus) => {
1109
+ const [unlocked, locked] = await bus.call({
1110
+ destination: SECRETS,
1111
+ path: SERVICE_PATH,
1112
+ interface: "org.freedesktop.Secret.Service",
1113
+ member: "SearchItems",
1114
+ signature: "a{ss}",
1115
+ body: [{
1116
+ service: KEYCHAIN_SERVICE,
1117
+ username: account
1118
+ }]
1119
+ });
1120
+ if (locked.length > 0) return true;
1121
+ if (unlocked.length > 0 || operation !== "set") return false;
1122
+ return property(bus, "Locked").then((isLocked) => isLocked === true, () => true);
1123
+ }),
1124
+ provider: (timeoutMs) => withBus(timeoutMs, async (bus) => {
1125
+ const [pid] = await bus.call({
1126
+ destination: "org.freedesktop.DBus",
1127
+ path: "/org/freedesktop/DBus",
1128
+ interface: "org.freedesktop.DBus",
1129
+ member: "GetConnectionUnixProcessID",
1130
+ signature: "s",
1131
+ body: [SECRETS]
1132
+ });
1133
+ const comm = (await readComm(pid)).trim();
1134
+ const label = await property(bus, "Label").catch(() => void 0);
1135
+ return {
1136
+ isKWallet: KWALLET_PROCESS.test(comm),
1137
+ walletLabel: typeof label === "string" ? label : void 0
1138
+ };
1139
+ })
1140
+ };
1141
+ }
715
1142
  // src/credentials/secret-service-process.ts
716
1143
  const CHILD_SCRIPT = `
717
1144
  const [op, service, account, timeoutMs, modulePath] = process.argv.slice(1);
@@ -737,7 +1164,7 @@ process.stdin.on("end", async () => {
737
1164
  }
738
1165
  });
739
1166
  `;
740
- function createSecretServiceProcess({ execPath, resolveKeyring }) {
1167
+ function createSecretServiceProcess({ execPath, resolveKeyring, lock }) {
741
1168
  const run = (op, account, timeoutMs, input = "") => new Promise((resolve, reject) => {
742
1169
  const child = spawn(execPath, [
743
1170
  "-e",
@@ -768,17 +1195,29 @@ function createSecretServiceProcess({ execPath, resolveKeyring }) {
768
1195
  child.stdin.on("error", () => {});
769
1196
  child.stdin.end(input);
770
1197
  });
771
- return {
1198
+ const operations = (perform) => ({
772
1199
  async get(account, timeoutMs) {
773
- const secret = await run("get", account, timeoutMs);
1200
+ const secret = await perform("get", account, timeoutMs);
774
1201
  return typeof secret === "string" ? secret : void 0;
775
1202
  },
776
1203
  async set(account, secret, timeoutMs) {
777
- await run("set", account, timeoutMs, secret);
1204
+ await perform("set", account, timeoutMs, secret);
778
1205
  },
779
1206
  async delete(account, timeoutMs) {
780
- await run("delete", account, timeoutMs);
781
- },
1207
+ await perform("delete", account, timeoutMs);
1208
+ }
1209
+ });
1210
+ const withoutPrompt = async (op, account, timeoutMs, input) => {
1211
+ const startedAt = Date.now();
1212
+ if (await lock.needsUnlock(op, account, timeoutMs)) throw new Error("the Secret Service is locked");
1213
+ const remainingMs = timeoutMs - (Date.now() - startedAt);
1214
+ if (remainingMs <= 0) throw new Error("Secret Service timed out");
1215
+ return run(op, account, remainingMs, input);
1216
+ };
1217
+ return {
1218
+ ...operations(run),
1219
+ withoutUnlockPrompt: () => operations(withoutPrompt),
1220
+ provider: (timeoutMs) => lock.provider(timeoutMs),
782
1221
  async list(timeoutMs) {
783
1222
  return (await run("list", "", timeoutMs)).map(({ account, password }) => ({
784
1223
  account,
@@ -1432,8 +1871,10 @@ What it does:
1432
1871
  and what to do next.
1433
1872
 
1434
1873
  Sends:
1435
- Per session: agent, session ID, repository, branch, commit, start and end
1436
- time, model, input and output token counts, and tool call counts.
1874
+ Per session: agent, session ID, repository, branch, start and end time,
1875
+ model, input and output token counts, and tool call counts. Claude Code
1876
+ sessions also send the first prompt ID; Codex sessions also send the commit
1877
+ the session started on.
1437
1878
 
1438
1879
  Does not send:
1439
1880
  Prompt text, model responses, tool input or output, and code diffs. Sessions
@@ -1445,6 +1886,38 @@ Exit codes:
1445
1886
 
1446
1887
  Examples:
1447
1888
  harnessforce import
1889
+ `
1890
+ },
1891
+ status: {
1892
+ summary: "Show the Workspace setup steps that remain",
1893
+ text: `Usage: harnessforce status [--json]
1894
+
1895
+ When to use:
1896
+ After init, and any time you want to see which setup steps of the connected
1897
+ Workspace remain and whether this repository is connected to the GitHub App.
1898
+
1899
+ What it does:
1900
+ Reads the setup checklist of the connected Workspace and the list of
1901
+ connected repositories, and prints your role, each step (done, to do with its
1902
+ URL, or "?" when it cannot be read), and whether the repository of the current
1903
+ directory (its origin remote) is connected.
1904
+
1905
+ Options:
1906
+ --json Print the checklist and the repository as one JSON object.
1907
+
1908
+ Sends:
1909
+ Read requests with the API token. It writes nothing.
1910
+
1911
+ Does not send:
1912
+ The URL of the origin remote: it is compared on this machine.
1913
+
1914
+ Exit codes:
1915
+ 0 The status was read, whether or not steps remain.
1916
+ 1 Not logged in, the login expired, or communication failed.
1917
+
1918
+ Examples:
1919
+ harnessforce status
1920
+ harnessforce status --json
1448
1921
  `
1449
1922
  },
1450
1923
  "otel-headers": {
@@ -1466,8 +1939,12 @@ Does not send:
1466
1939
 
1467
1940
  Exit codes:
1468
1941
  0 Header printed.
1469
- 1 No usable key (not logged in, keychain unavailable, or the destination was
1470
- not verified). Nothing is printed to stdout.
1942
+ 3 No key for the Workspace (not logged in, or the keychain item is gone, for
1943
+ example after a reboot cleared the kernel keyring).
1944
+ 4 The destination was not verified: it differs from the origin init pinned.
1945
+ 5 This CLI version is unsupported. The keychain is not read.
1946
+ 1 Any other failure (keychain unavailable, read failed, or timed out).
1947
+ Nothing is printed to stdout unless the exit code is 0.
1471
1948
 
1472
1949
  Examples:
1473
1950
  harnessforce otel-headers
@@ -1509,6 +1986,8 @@ Examples:
1509
1986
  tune: {
1510
1987
  summary: "Analyze your past sessions and report counts",
1511
1988
  text: `Usage: harnessforce tune [--all] [--no-send] [--show-report] [--json]
1989
+ harnessforce tune --report-only [--all]
1990
+ harnessforce tune --print-schedule
1512
1991
  harnessforce tune record
1513
1992
  harnessforce tune --purge
1514
1993
 
@@ -1528,6 +2007,11 @@ Options:
1528
2007
  --show-report Print the exact values that would be sent.
1529
2008
  --json Print the result as one JSON object on stdout. Messages go to
1530
2009
  stderr.
2010
+ --report-only Analyze and send, writing nothing to stdout (for your OS
2011
+ scheduler). Combines with --all only.
2012
+ --print-schedule
2013
+ Print a cron line that runs --report-only every Monday at
2014
+ 9:00. It changes nothing and sends nothing.
1531
2015
  record Read one proposal as JSON from stdin and store it locally.
1532
2016
  --purge Delete the analysis, proposals, and unsent results.
1533
2017
 
@@ -1549,6 +2033,7 @@ Exit codes:
1549
2033
  Examples:
1550
2034
  harnessforce tune
1551
2035
  harnessforce tune --no-send --json
2036
+ harnessforce tune --print-schedule
1552
2037
  harnessforce tune --purge
1553
2038
  `
1554
2039
  },
@@ -1623,6 +2108,7 @@ Examples:
1623
2108
  const ORDER = [
1624
2109
  "init",
1625
2110
  "import",
2111
+ "status",
1626
2112
  "run",
1627
2113
  "tune",
1628
2114
  "top",
@@ -1676,7 +2162,7 @@ const DESTINATION_MISMATCH_WARNING = {
1676
2162
  // ../agent-core/src/issue.ts
1677
2163
  const isIssueIdentifier = (value) => isToken(value, 300);
1678
2164
  // ../agent-core/src/using-harnessforce.ts
1679
- const usingHarnessforceBody = "---\nname: using-harnessforce\ndescription: Use when starting a session in an environment with the Harnessforce hook, when starting work on an Issue, or when asked what is recorded or collected. The hook injects this text into the context at session start.\n---\n\n# Using Harnessforce\n\nThis session has the Harnessforce hook installed. Harnessforce links the work of people and AI to Issues and records it. Follow these rules.\n\n## What is recorded\n\n- Session registration: agent, session ID, repository, branch, commit, start time, and the first prompt ID.\n- Work locations: the repository and branch of each Git working tree the session works in. Paths are not sent.\n- Config snapshot: the structure of the configuration in use (Claude Code only).\n- OTel telemetry: what the user chooses to send.\n- For work on an Issue, `record-run` registers the session as a Run and closes it. It records no Plan, Decision, or Definition of Done.\n\n## What is not collected\n\n- Prompts, responses, tool input and output, and the body of code diffs arrive only when the user sends them (`--send-content` or managed settings) and the Workspace stores that category. The members the Workspace chooses can read them. `harnessforce import` never sends them.\n- Never put these contents, secrets, or environment variable values in MCP tool input or in a record.\n\n## Which hook sends what\n\n- SessionStart: session registration and the config snapshot (Codex sends session registration only).\n- UserPromptSubmit: resends on the first prompt (Claude Code only).\n- Stop: sends the work locations at the end of a turn in which they changed.\n- A failing hook never stops the session. When the hook cannot send, it shows a one-line warning. Tell the user when a warning appears.\n\n## Which skill and tool to use, and when\n\n- For work on a specified Issue ID, or when the environment variable `HARNESSFORCE_ISSUE` is set, follow `record-run` and use `start_run`, `get_issue`, and `complete_run`. An agent that cannot load the skill calls the tools in the same order.\n- Pass the value of the `harnessforce session_id:` line in the context to `start_run`. If there are several, use the last one. If the line is missing, do not guess the session ID, and do not call `start_run` or `complete_run`.\n- Use `propose-improvements` for the analysis of `/harnessforce:tune`.\n- When no Issue is specified, do not start recording a Run.\n\n## Red Flags\n\nStop when one of these thoughts comes up.\n\n| Thought | Reality |\n| --- | --- |\n| An Issue was specified, but the work is small, so recording is unnecessary | Work on a specified Issue is recorded regardless of its size |\n| The session ID line is missing, so I will guess it and call `start_run` | Do not guess. The Run would no longer be linked to the session |\n| Summarizing the content before recording would help | Do not pass content to the tool. It goes beyond what is collected |\n| A warning appeared, but I can ignore it | Sending is not working. Tell the user |\n\n## Priority\n\n1. The user's instructions (CLAUDE.md, AGENTS.md, direct requests). If the user tells you to stop recording, stop recording the Run.\n2. This introduction.\n3. Default behavior.\n\n## Subagents\n\nA subagent that was delegated only a specific task by its parent does not register a Run by following this introduction. The parent registers the Run.\n".replace(/\r\n/g, "\n").replace(/^---\n[\s\S]*?\n---\n+/, "").trimEnd();
2165
+ const usingHarnessforceBody = "---\nname: using-harnessforce\ndescription: Use when starting a session in an environment with the Harnessforce hook, when starting work on an Issue, or when asked what is recorded or collected. The hook injects this text into the context at session start.\n---\n\n# Using Harnessforce\n\nThis session has the Harnessforce hook installed. Harnessforce links the work of people and AI to Issues and records it. Follow these rules.\n\n## What is recorded\n\n- Session registration: agent, session ID, repository, branch, commit, start time, and the first prompt ID.\n- Work locations: the repository and branch of each Git working tree the session works in. Paths are not sent.\n- Config snapshot: the structure of the configuration in use (Claude Code only).\n- OTel telemetry: what the user chooses to send.\n- For work on an Issue, `record-run` registers the session as a Run and closes it. It records no Plan, Decision, or Definition of Done.\n\n## What is not collected\n\n- Prompts, responses, tool input and output, and the body of code diffs arrive only when the user sends them (`--send-content` or managed settings) and the Workspace stores that category. The members the Workspace chooses can read them. `harnessforce import` never sends them.\n- Never put these contents, secrets, or environment variable values in MCP tool input or in a record.\n\n## Which hook sends what\n\n- SessionStart: session registration and the config snapshot (Codex sends session registration only).\n- UserPromptSubmit: resends on the first prompt (Claude Code only).\n- Stop: sends the work locations at the end of a turn in which they changed.\n- A failing hook never stops the session. When the hook cannot send, it shows a one-line warning. Tell the user when a warning appears.\n\n## Which skill and tool to use, and when\n\n- For work on a specified Issue ID, or when the environment variable `HARNESSFORCE_ISSUE` is set, follow `record-run` and use `start_run`, `get_issue`, and `complete_run`. An agent that cannot load the skill calls the tools in the same order.\n- Pass the value of the `harnessforce session_id:` line in the context to `start_run`. If there are several, use the last one. If the line is missing, do not guess the session ID, and do not call `start_run` or `complete_run`.\n- Use `propose-improvements` for the analysis of `/harnessforce:tune`.\n- When no Issue is specified, do not start recording a Run on your own. For work that commits changes, follow \"When no Issue is specified\" in `record-run` and ask the user once whether to link it to an existing Issue.\n\n## Red Flags\n\nStop when one of these thoughts comes up.\n\n| Thought | Reality |\n| --- | --- |\n| An Issue was specified, but the work is small, so recording is unnecessary | Work on a specified Issue is recorded regardless of its size |\n| The session ID line is missing, so I will guess it and call `start_run` | Do not guess. The Run would no longer be linked to the session |\n| Summarizing the content before recording would help | Do not pass content to the tool. It goes beyond what is collected |\n| A warning appeared, but I can ignore it | Sending is not working. Tell the user |\n\n## Priority\n\n1. The user's instructions (CLAUDE.md, AGENTS.md, direct requests). If the user tells you to stop recording, stop recording the Run.\n2. This introduction.\n3. Default behavior.\n\n## Subagents\n\nA subagent that was delegated only a specific task by its parent does not register a Run by following this introduction. The parent registers the Run.\n".replace(/\r\n/g, "\n").replace(/^---\n[\s\S]*?\n---\n+/, "").trimEnd();
1680
2166
  // ../agent-core/src/session-context.ts
1681
2167
  const sessionContextLine = (sessionId) => `harnessforce session_id: ${sessionId}`;
1682
2168
  const sessionContext = (sessionId) => ({ hookSpecificOutput: {
@@ -2382,6 +2868,48 @@ async function fetchSessionImportDays(base, apiToken, fetchImpl) {
2382
2868
  value: days
2383
2869
  } : FAILED$1;
2384
2870
  }
2871
+ const ROLES = /* @__PURE__ */ new Set([
2872
+ "owner",
2873
+ "admin",
2874
+ "member",
2875
+ "viewer"
2876
+ ]);
2877
+ const STEP_STATUSES = /* @__PURE__ */ new Set([
2878
+ "done",
2879
+ "todo",
2880
+ "unavailable"
2881
+ ]);
2882
+ function parseSteps(value) {
2883
+ if (!Array.isArray(value)) return void 0;
2884
+ const steps = value.map((item) => {
2885
+ if (typeof item !== "object" || item === null) return void 0;
2886
+ const { step, status, url } = item;
2887
+ return Number.isInteger(step) && step >= 2 && step <= 5 && STEP_STATUSES.has(status) && typeof url === "string" ? {
2888
+ step,
2889
+ status,
2890
+ url
2891
+ } : void 0;
2892
+ });
2893
+ return steps.every((step) => step !== void 0) ? steps : void 0;
2894
+ }
2895
+ async function fetchOnboarding(base, apiToken, fetchImpl) {
2896
+ const outcome = await getJson(underBase(base, "api/v1/onboarding"), apiToken, fetchImpl);
2897
+ if (outcome.kind !== "ok") return outcome;
2898
+ const body = outcome.value;
2899
+ const steps = parseSteps(body.steps);
2900
+ const adminSteps = body.admin_steps === void 0 ? void 0 : parseSteps(body.admin_steps);
2901
+ if (typeof body.workspace_name !== "string" || !ROLES.has(body.role) || typeof body.has_seat !== "boolean" || !steps || body.admin_steps !== void 0 && !adminSteps) return FAILED$1;
2902
+ return {
2903
+ kind: "ok",
2904
+ value: {
2905
+ workspace_name: body.workspace_name,
2906
+ role: body.role,
2907
+ has_seat: body.has_seat,
2908
+ steps,
2909
+ ...adminSteps && { admin_steps: adminSteps }
2910
+ }
2911
+ };
2912
+ }
2385
2913
  // src/import/record-base.ts
2386
2914
  async function readSettingsEnv(path) {
2387
2915
  const read = await readUserSettings(path);
@@ -3254,7 +3782,7 @@ async function scanCodexSessions(options) {
3254
3782
  }
3255
3783
  // src/import/command.ts
3256
3784
  const DAY_MS$1 = 864e5;
3257
- const MESSAGES = {
3785
+ const MESSAGES$1 = {
3258
3786
  runInit: "Run `harnessforce init`",
3259
3787
  invalidUrl: "The connection URL is invalid",
3260
3788
  keychainUnavailable: INIT_MESSAGES.keychainUnavailable,
@@ -3271,15 +3799,15 @@ const limitMessage = {
3271
3799
  monthly_event_limit: (count) => `Could not import ${count} session(s) because the monthly event limit was reached. Run \`harnessforce import\` again after the limit is lifted to resume importing`,
3272
3800
  workspace_read_only: (count) => `Could not import ${count} session(s) because the Workspace is view-only. Run \`harnessforce import\` again after view-only mode is lifted to resume importing`
3273
3801
  };
3274
- const ACCESS_MESSAGES$2 = {
3275
- keychainUnavailable: MESSAGES.keychainUnavailable,
3276
- initRequired: MESSAGES.runInit,
3277
- invalidUrl: MESSAGES.invalidUrl,
3278
- apiTokenMissing: MESSAGES.runInit
3802
+ const ACCESS_MESSAGES$3 = {
3803
+ keychainUnavailable: MESSAGES$1.keychainUnavailable,
3804
+ initRequired: MESSAGES$1.runInit,
3805
+ invalidUrl: MESSAGES$1.invalidUrl,
3806
+ apiTokenMissing: MESSAGES$1.runInit
3279
3807
  };
3280
- function unwrap(outcome) {
3808
+ function unwrap$1(outcome) {
3281
3809
  if (outcome.kind === "ok") return outcome.value;
3282
- return stopWith(outcome.kind === "unauthorized" ? MESSAGES.loginExpired : MESSAGES.readFailed);
3810
+ return stopWith(outcome.kind === "unauthorized" ? MESSAGES$1.loginExpired : MESSAGES$1.readFailed);
3283
3811
  }
3284
3812
  const NOT_IMPORTED_LABELS = [
3285
3813
  ["alreadySent", "already sent"],
@@ -3295,8 +3823,8 @@ function reportNotImported(imported, notes, deps) {
3295
3823
  const line = breakdownLine(notes.breakdown);
3296
3824
  if (line) deps.stdout(`${line}\n`);
3297
3825
  if (imported > 0) return;
3298
- if (notes.breakdown.notConnected > 0) deps.stdout(`${notes.connectedCount === 0 ? MESSAGES.connectNone : MESSAGES.connectOther}\n`);
3299
- else if (!line && !notes.otherNoticeShown) deps.stdout(`${MESSAGES.nothingFound}\n`);
3826
+ if (notes.breakdown.notConnected > 0) deps.stdout(`${notes.connectedCount === 0 ? MESSAGES$1.connectNone : MESSAGES$1.connectOther}\n`);
3827
+ else if (!line && !notes.otherNoticeShown) deps.stdout(`${MESSAGES$1.nothingFound}\n`);
3300
3828
  }
3301
3829
  function report(result, notes, deps) {
3302
3830
  switch (result.kind) {
@@ -3312,10 +3840,10 @@ function report(result, notes, deps) {
3312
3840
  deps.stderr(`${limitMessage[result.reason](result.notImported)}\n`);
3313
3841
  return 1;
3314
3842
  case "unauthorized":
3315
- deps.stderr(`${MESSAGES.revoked}\n`);
3843
+ deps.stderr(`${MESSAGES$1.revoked}\n`);
3316
3844
  return 1;
3317
3845
  case "failed":
3318
- deps.stderr(`${MESSAGES.sendFailed}\n`);
3846
+ deps.stderr(`${MESSAGES$1.sendFailed}\n`);
3319
3847
  return 1;
3320
3848
  }
3321
3849
  }
@@ -3325,7 +3853,7 @@ const sumNotImported = (a, b) => ({
3325
3853
  notConnected: a.notConnected + b.notConnected
3326
3854
  });
3327
3855
  async function runImport(deps) {
3328
- const access = await verifyCliAccess(deps, ACCESS_MESSAGES$2);
3856
+ const access = await verifyCliAccess(deps, ACCESS_MESSAGES$3);
3329
3857
  const destination = {
3330
3858
  workspaceId: access.workspaceId,
3331
3859
  endpoint: withoutExtras(access.ingest)
@@ -3339,8 +3867,8 @@ async function runImport(deps) {
3339
3867
  sleep: deps.sleep,
3340
3868
  homeDir: deps.homeDir
3341
3869
  }).authorizedFetch(deps.fetch);
3342
- const connected = unwrap(await listConnectedRepositories(access.readApiBase, access.accessToken, readFetch));
3343
- const days = unwrap(await fetchSessionImportDays(access.readApiBase, access.accessToken, readFetch));
3870
+ const connected = unwrap$1(await listConnectedRepositories(access.readApiBase, access.accessToken, readFetch));
3871
+ const days = unwrap$1(await fetchSessionImportDays(access.readApiBase, access.accessToken, readFetch));
3344
3872
  const sinceMs = deps.now() - days * DAY_MS$1;
3345
3873
  const claude = await scanSessions({
3346
3874
  projectsDir: join(await recordBase(deps), "projects"),
@@ -3369,7 +3897,7 @@ async function runImport(deps) {
3369
3897
  sessions: unsent,
3370
3898
  fetch: deps.fetch,
3371
3899
  sleep: deps.sleep,
3372
- record: (ids) => recordSentSessions(statePath, destination, ids).catch(() => stopWith(MESSAGES.stateFailed))
3900
+ record: (ids) => recordSentSessions(statePath, destination, ids).catch(() => stopWith(MESSAGES$1.stateFailed))
3373
3901
  }), {
3374
3902
  breakdown: {
3375
3903
  alreadySent: scanned.length - unsent.length,
@@ -10068,7 +10596,10 @@ function finishInit(issued, args, deps, result) {
10068
10596
  async function openKeychain(deps, previousWorkspaceId) {
10069
10597
  return await deps.keychain.openForInit({
10070
10598
  previousWorkspaceId,
10071
- onWaiting: () => deps.stderr(`${INIT_MESSAGES.waitingForSecretService}\n`)
10599
+ onWaiting: (provider) => {
10600
+ deps.stderr(`${INIT_MESSAGES.waitingForSecretService}\n`);
10601
+ if (provider?.isKWallet) deps.stderr(`${kwalletUnlockHint(provider.walletLabel)}\n`);
10602
+ }
10072
10603
  }).catch(() => stop$1("keychainUnavailable")) ?? stopWith(keychainUnavailableMessage(deps.keychain));
10073
10604
  }
10074
10605
  function revokeHashes({ items }) {
@@ -10579,14 +11110,14 @@ function candidateList(candidates) {
10579
11110
  const lines = candidates.map(({ identifier, title }) => `${printable(identifier)} ${printable(title)}`);
10580
11111
  return [RUN_MESSAGES.candidatesHeader, ...lines].join("\n");
10581
11112
  }
10582
- const ACCESS_MESSAGES$1 = {
11113
+ const ACCESS_MESSAGES$2 = {
10583
11114
  keychainUnavailable: RUN_MESSAGES.keychainUnavailable,
10584
11115
  initRequired: RUN_MESSAGES.initRequired,
10585
11116
  invalidUrl: RUN_MESSAGES.invalidUrl,
10586
11117
  apiTokenMissing: RUN_MESSAGES.issueInitRequired
10587
11118
  };
10588
11119
  async function prepareLaunch(args, deps) {
10589
- const { workspaceId, ingestEndpoint, readApiBase, accessToken } = await verifyCliAccess(deps, ACCESS_MESSAGES$1);
11120
+ const { workspaceId, ingestEndpoint, readApiBase, accessToken } = await verifyCliAccess(deps, ACCESS_MESSAGES$2);
10590
11121
  if (!isIssueIdentifier(args.issue)) stop("invalidIssue");
10591
11122
  const readFetch = createApiTokenSession({
10592
11123
  keychain: deps.keychain,
@@ -10651,6 +11182,87 @@ async function runWithoutHarnessforce(args, deps) {
10651
11182
  deps.stderr(`${launchFailedMessage(args.agent)}\n`);
10652
11183
  return 1;
10653
11184
  }
11185
+ // src/status/command.ts
11186
+ const STATUS_USAGE = "Usage: harnessforce status [--json]\n";
11187
+ const MESSAGES = {
11188
+ failed: "Could not read the Harnessforce status. Run `harnessforce init` and try again.",
11189
+ loginExpired: "Your login has expired. Run `harnessforce init`"
11190
+ };
11191
+ const ACCESS_MESSAGES$1 = {
11192
+ keychainUnavailable: INIT_MESSAGES.keychainUnavailable,
11193
+ initRequired: MESSAGES.failed,
11194
+ invalidUrl: MESSAGES.failed,
11195
+ apiTokenMissing: MESSAGES.failed
11196
+ };
11197
+ const STEP_TITLES = {
11198
+ 2: "Connect the GitHub App",
11199
+ 3: "Assign repositories to a project",
11200
+ 4: "Set up telemetry",
11201
+ 5: "Wait for your first run"
11202
+ };
11203
+ const STATUS_MARKS = {
11204
+ done: "✓",
11205
+ todo: "・",
11206
+ unavailable: "?"
11207
+ };
11208
+ function unwrap(outcome) {
11209
+ if (outcome.kind === "ok") return outcome.value;
11210
+ return stopWith(outcome.kind === "unauthorized" ? MESSAGES.loginExpired : MESSAGES.failed);
11211
+ }
11212
+ async function originRepository(cwd, git) {
11213
+ const url = await git(cwd, [
11214
+ "remote",
11215
+ "get-url",
11216
+ "origin"
11217
+ ]);
11218
+ return url === void 0 ? void 0 : normalizeRepository(url);
11219
+ }
11220
+ function stepLine(step) {
11221
+ const parts = [STATUS_MARKS[step.status], STEP_TITLES[step.step]];
11222
+ if (step.status === "todo") parts.push(step.url);
11223
+ return parts.join(" ");
11224
+ }
11225
+ async function runStatus(json, deps) {
11226
+ const access = await verifyCliAccess(deps, ACCESS_MESSAGES$1);
11227
+ const readFetch = createApiTokenSession({
11228
+ keychain: deps.keychain,
11229
+ workspaceId: access.workspaceId,
11230
+ readBase: access.readApiBase,
11231
+ fetch: deps.fetch,
11232
+ now: deps.now,
11233
+ sleep: deps.sleep,
11234
+ homeDir: deps.homeDir
11235
+ }).authorizedFetch(deps.fetch);
11236
+ const onboarding = unwrap(await fetchOnboarding(access.readApiBase, access.accessToken, readFetch));
11237
+ const name = await originRepository(deps.cwd, deps.git);
11238
+ const repository = name === void 0 ? null : {
11239
+ name,
11240
+ connected: unwrap(await listConnectedRepositories(access.readApiBase, access.accessToken, readFetch)).has(name)
11241
+ };
11242
+ if (json) {
11243
+ deps.stdout(`${JSON.stringify({
11244
+ ...onboarding,
11245
+ repository
11246
+ })}\n`);
11247
+ return 0;
11248
+ }
11249
+ const lines = [...onboarding.admin_steps ?? [], ...onboarding.steps].sort((a, b) => a.step - b.step).map(stepLine);
11250
+ const repositoryLine = repository && `This repository (${repository.name}) is ${repository.connected ? "connected" : "not connected"} to the GitHub App`;
11251
+ deps.stdout(`${[
11252
+ `${onboarding.workspace_name} (${onboarding.role})`,
11253
+ ...lines,
11254
+ ...repositoryLine ? [repositoryLine] : []
11255
+ ].join("\n")}\n`);
11256
+ return 0;
11257
+ }
11258
+ function statusCommand(args, deps) {
11259
+ const json = args.length === 1 && args[0] === "--json";
11260
+ if (args.length > 0 && !json) {
11261
+ deps.stderr(STATUS_USAGE);
11262
+ return Promise.resolve(1);
11263
+ }
11264
+ return runUntilStop(() => runStatus(json, deps), deps.stderr);
11265
+ }
10654
11266
  // src/top/args.ts
10655
11267
  const TOP_USAGE = "Usage: harnessforce top [--once] [--json] [--ascii] [--theme <auto|light|dark|ansi>]\n";
10656
11268
  const THEMES = [
@@ -12284,7 +12896,7 @@ const TUNE_MESSAGES = {
12284
12896
  unwritable: "Cannot write to `~/.harnessforce/tune/`, so the analysis results are not sent",
12285
12897
  recordFailed: "Could not record the suggestion",
12286
12898
  recordInvalid: (field) => `The suggestion record input is invalid: ${field}`,
12287
- usage: "Usage: harnessforce tune [--all] [--no-send] [--show-report] [--json], harnessforce tune record, or harnessforce tune --purge",
12899
+ usage: "Usage: harnessforce tune [--all] [--no-send] [--show-report] [--json], harnessforce tune --report-only [--all], harnessforce tune --print-schedule, harnessforce tune record, or harnessforce tune --purge",
12288
12900
  purged: "Deleted the analysis results, suggestions and unsent analysis results in `~/.harnessforce/tune/`",
12289
12901
  purgeFailed: "Could not delete some files in `~/.harnessforce/tune/`"
12290
12902
  };
@@ -12870,7 +13482,7 @@ async function analyzeUnderLock(options, access, paths, deps) {
12870
13482
  ]).then(() => true, () => false);
12871
13483
  if (!isWritten) notices.add("unwritable", TUNE_MESSAGES.unwritable, 1);
12872
13484
  if (options.json) deps.stdout(`${asciiJson(output)}\n`);
12873
- else {
13485
+ else if (!options.reportOnly) {
12874
13486
  deps.stdout(renderAnalysis(analyzed.map((a) => a.report), analyzed.map((a) => a.tune.usage), {
12875
13487
  sessionCount: analyzed.length,
12876
13488
  analyzerVersion: ANALYZER_VERSION,
@@ -13110,27 +13722,36 @@ const FLAGS = {
13110
13722
  "--all": "all",
13111
13723
  "--no-send": "noSend",
13112
13724
  "--show-report": "showReport",
13113
- "--json": "json"
13725
+ "--json": "json",
13726
+ "--report-only": "reportOnly"
13114
13727
  };
13728
+ const REPORT_ONLY = "--report-only";
13115
13729
  function parseArgs(args) {
13116
13730
  if (args.length === 1 && args[0] === "record") return { kind: "record" };
13117
13731
  if (args.length === 1 && args[0] === "--purge") return { kind: "purge" };
13732
+ if (args.length === 1 && args[0] === "--print-schedule") return { kind: "printSchedule" };
13118
13733
  const options = {
13119
13734
  all: false,
13120
13735
  noSend: false,
13121
13736
  showReport: false,
13122
- json: false
13737
+ json: false,
13738
+ reportOnly: false
13123
13739
  };
13124
13740
  for (const arg of args) {
13125
13741
  const key = FLAGS[arg];
13126
13742
  if (key === void 0 || options[key]) return void 0;
13127
13743
  options[key] = true;
13128
13744
  }
13745
+ if (options.reportOnly && (options.noSend || options.showReport || options.json)) return void 0;
13129
13746
  return {
13130
13747
  kind: "analyze",
13131
13748
  options
13132
13749
  };
13133
13750
  }
13751
+ function cronCommandPath(path) {
13752
+ return (/^[A-Za-z0-9_./-]+$/.test(path) ? path : `'${path.replaceAll("'", "'\\''")}'`).replaceAll("%", "\\%");
13753
+ }
13754
+ const scheduleLine = (selfPath) => `0 9 * * 1 ${cronCommandPath(selfPath)} tune ${REPORT_ONLY}\n`;
13134
13755
  function canTuneSend(args) {
13135
13756
  const parsed = parseArgs(args);
13136
13757
  return parsed?.kind === "analyze" && !parsed.options.noSend;
@@ -13180,6 +13801,10 @@ async function tuneCommand(args, deps) {
13180
13801
  return 2;
13181
13802
  }
13182
13803
  if (parsed.kind === "record") return record(deps);
13804
+ if (parsed.kind === "printSchedule") {
13805
+ deps.stdout(scheduleLine(deps.selfPath));
13806
+ return 0;
13807
+ }
13183
13808
  if (parsed.kind === "purge") return underLock(deps, TUNE_MESSAGES.purgeFailed, async (paths) => {
13184
13809
  if (!await purgeTuneFiles(paths)) return stopWith(TUNE_MESSAGES.purgeFailed);
13185
13810
  deps.stderr(`${TUNE_MESSAGES.purged}\n`);
@@ -13284,6 +13909,11 @@ async function run(argv, deps) {
13284
13909
  git: cli.importGit,
13285
13910
  now: () => cli.now().getTime()
13286
13911
  }));
13912
+ if (command === "status") return guarded(() => statusCommand(rest, {
13913
+ ...cli,
13914
+ git: cli.importGit,
13915
+ now: () => cli.now().getTime()
13916
+ }));
13287
13917
  if (command === "tune") return guarded(() => tuneCommand(rest, {
13288
13918
  ...cli,
13289
13919
  git: cli.importGit,
@@ -13341,6 +13971,7 @@ const SENDING_COMMANDS = /* @__PURE__ */ new Set([
13341
13971
  "init",
13342
13972
  "run",
13343
13973
  "import",
13974
+ "status",
13344
13975
  "hook"
13345
13976
  ]);
13346
13977
  const isSending = (argv) => SENDING_COMMANDS.has(argv[0] ?? "") || argv[0] === "tune" && canTuneSend(argv.slice(1));
@@ -13523,7 +14154,12 @@ process.exitCode = relaunched ?? await run(argv, {
13523
14154
  load: () => import("@napi-rs/keyring"),
13524
14155
  secretService: createSecretServiceProcess({
13525
14156
  execPath: process.execPath,
13526
- resolveKeyring: () => require.resolve("@napi-rs/keyring")
14157
+ resolveKeyring: () => require.resolve("@napi-rs/keyring"),
14158
+ lock: createSecretServiceLock({
14159
+ address: sessionBusAddress(process.env),
14160
+ uid: process.getuid?.() ?? 0,
14161
+ readComm: (pid) => readFile(`/proc/${pid}/comm`, "utf8")
14162
+ })
13527
14163
  })
13528
14164
  }),
13529
14165
  stdout: (text) => process.stdout.write(text),
@@ -13543,6 +14179,7 @@ process.exitCode = relaunched ?? await run(argv, {
13543
14179
  devBuild: !isDefault,
13544
14180
  callbackTimeoutMs: CALLBACK_TIMEOUT_MS,
13545
14181
  cwd: process.cwd(),
14182
+ selfPath: process.argv[1],
13546
14183
  git: createGitRunner({
13547
14184
  platform: process.platform,
13548
14185
  env: process.env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harnessforce/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Harnessforce command line (harnessforce)",
5
5
  "keywords": [
6
6
  "harnessforce",