@indigoai-us/hq-cli 5.345.104 → 5.345.105
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/CHANGELOG.md +93 -0
- package/assets/bot-workers/setup/skills/add-a-pack.md +130 -0
- package/assets/bot-workers/setup/worker.yaml +3 -0
- package/dist/command-catalog.generated.d.ts +206 -0
- package/dist/command-catalog.generated.js +246 -0
- package/dist/commands/bot.d.ts +10 -0
- package/dist/commands/bot.js +20 -12
- package/dist/commands/daemon.js +385 -1
- package/dist/commands/integrations-connect.d.ts +6 -0
- package/dist/commands/integrations-connect.js +36 -0
- package/dist/commands/integrations-slack.d.ts +30 -0
- package/dist/commands/integrations-slack.js +81 -0
- package/dist/commands/integrations.js +2 -0
- package/dist/commands/pack-install.d.ts +20 -1
- package/dist/commands/pack-install.js +80 -5
- package/dist/commands/pack-start-bot.d.ts +24 -0
- package/dist/commands/pack-start-bot.js +76 -0
- package/dist/commands/packs.d.ts +9 -0
- package/dist/commands/packs.js +26 -3
- package/dist/commands/pkg-install.js +11 -2
- package/dist/commands/secrets.d.ts +1 -5
- package/dist/commands/secrets.js +2 -92
- package/dist/lib/bot/config.d.ts +7 -0
- package/dist/lib/bot/daemon.js +2 -1
- package/dist/lib/bot/index.d.ts +2 -0
- package/dist/lib/bot/index.js +2 -0
- package/dist/lib/bot/lean-settings.d.ts +16 -0
- package/dist/lib/bot/lean-settings.js +34 -0
- package/dist/lib/bot/pack-link.d.ts +66 -0
- package/dist/lib/bot/pack-link.js +153 -0
- package/dist/lib/bot/pack-setup-driver.d.ts +37 -0
- package/dist/lib/bot/pack-setup-driver.js +83 -0
- package/dist/lib/bot/pack-setup.d.ts +62 -0
- package/dist/lib/bot/pack-setup.js +103 -0
- package/dist/lib/bot/prompt.d.ts +8 -0
- package/dist/lib/bot/prompt.js +5 -0
- package/dist/lib/bot/run.d.ts +18 -2
- package/dist/lib/bot/run.js +163 -25
- package/dist/lib/bot/runtime/claude.d.ts +27 -2
- package/dist/lib/bot/runtime/claude.js +49 -4
- package/dist/lib/bot/runtime/types.d.ts +5 -0
- package/dist/lib/bot/runtime-failure.d.ts +18 -2
- package/dist/lib/bot/runtime-failure.js +21 -1
- package/dist/lib/bot/toolchain.d.ts +25 -0
- package/dist/lib/bot/toolchain.js +33 -0
- package/dist/lib/bot/worker-source.d.ts +1 -0
- package/dist/lib/bot/worker-source.js +7 -1
- package/dist/lib/daemon/catalog.d.ts +15 -0
- package/dist/lib/daemon/catalog.js +49 -0
- package/dist/lib/daemon/paths.d.ts +1 -0
- package/dist/lib/daemon/paths.js +1 -0
- package/dist/lib/daemon/remote-control-forwarder.d.ts +14 -0
- package/dist/lib/daemon/remote-control-forwarder.js +104 -0
- package/dist/lib/daemon/remote-control-pools.d.ts +44 -0
- package/dist/lib/daemon/remote-control-pools.js +245 -0
- package/dist/lib/daemon/remote-control.d.ts +4 -0
- package/dist/lib/daemon/remote-control.js +17 -2
- package/dist/lib/daemon/run.js +126 -0
- package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +5 -4
- package/dist/lib/doctor/fix/refresh-sync-progress.js +5 -4
- package/dist/lib/doctor/json-output.d.ts +5 -5
- package/dist/lib/doctor/json-output.js +2 -5
- package/dist/lib/packages/setup-block.d.ts +43 -0
- package/dist/lib/packages/setup-block.js +236 -0
- package/dist/lib/packages/setup-prompt.d.ts +34 -0
- package/dist/lib/packages/setup-prompt.js +128 -0
- package/dist/lib/packages/setup-takeover.d.ts +50 -0
- package/dist/lib/packages/setup-takeover.js +75 -0
- package/dist/lib/packages/start-bot.d.ts +47 -0
- package/dist/lib/packages/start-bot.js +66 -0
- package/dist/lib/session-company-lock.d.ts +7 -0
- package/dist/lib/session-company-lock.js +97 -0
- package/dist/types.d.ts +82 -0
- package/package.json +1 -1
package/dist/lib/daemon/run.js
CHANGED
|
@@ -22,6 +22,8 @@ import { readBotConfig } from "../bot/config.js";
|
|
|
22
22
|
import { botsRoot } from "../bot/paths.js";
|
|
23
23
|
import { CLAUDE_RUNNER_SCRIPT, CODEX_RUNNER_SCRIPT, enabledServices, planUnits } from "./catalog.js";
|
|
24
24
|
import { effectiveSyncOwner, readDaemonConfig } from "./config.js";
|
|
25
|
+
import { assertPersonalServiceSessionCapability, readRemoteControlPoolsConfig, remoteControlPoolSecretsEnv, } from "./remote-control-pools.js";
|
|
26
|
+
import { loadLanesConfig } from "../lanes/config.js";
|
|
25
27
|
import { SUPERVISOR_HANDOFF_FLAG, readDaemonFlag } from "./flag.js";
|
|
26
28
|
import { findLegacyUnits, legacyMeshUnitsToRetire, legacyOwnerLookup } from "./legacy.js";
|
|
27
29
|
import { readLegacySyncEnv } from "./legacy-sync-env.js";
|
|
@@ -636,6 +638,45 @@ export async function runDaemon(opts) {
|
|
|
636
638
|
};
|
|
637
639
|
let warnedMissingPin = null;
|
|
638
640
|
let warnedMissingSyncRunner = false;
|
|
641
|
+
const poolProbeCache = new Map();
|
|
642
|
+
const poolProbeProcess = (file, args, options) => new Promise((resolve) => {
|
|
643
|
+
const child = spawn(file, args, { cwd: options.cwd, env: options.env, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
644
|
+
let stdout = "";
|
|
645
|
+
let error = false;
|
|
646
|
+
child.stdout?.on("data", (chunk) => { stdout += chunk.toString().slice(0, 256); });
|
|
647
|
+
child.on("error", () => { error = true; });
|
|
648
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), options.timeout);
|
|
649
|
+
child.once("close", (status) => { clearTimeout(timer); resolve({ status, stdout, error }); });
|
|
650
|
+
});
|
|
651
|
+
const poolProbe = (key, probe) => {
|
|
652
|
+
const cached = poolProbeCache.get(key);
|
|
653
|
+
if (cached && (cached.pending || Date.now() - cached.at < 30_000))
|
|
654
|
+
return cached.value;
|
|
655
|
+
let result;
|
|
656
|
+
try {
|
|
657
|
+
result = probe();
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
log("error", `remote-control pool probe ${key} failed: ${error.message}`);
|
|
661
|
+
poolProbeCache.set(key, { at: Date.now(), value: "unavailable" });
|
|
662
|
+
return "unavailable";
|
|
663
|
+
}
|
|
664
|
+
if (typeof result === "string") {
|
|
665
|
+
poolProbeCache.set(key, { at: Date.now(), value: result });
|
|
666
|
+
return result;
|
|
667
|
+
}
|
|
668
|
+
const pending = { at: Date.now(), value: cached?.value ?? "unavailable", pending: true };
|
|
669
|
+
poolProbeCache.set(key, pending);
|
|
670
|
+
result.then((value) => {
|
|
671
|
+
if (poolProbeCache.get(key) === pending)
|
|
672
|
+
poolProbeCache.set(key, { at: Date.now(), value });
|
|
673
|
+
}, (error) => {
|
|
674
|
+
log("error", `remote-control pool probe ${key} failed: ${error.message}`);
|
|
675
|
+
if (poolProbeCache.get(key) === pending)
|
|
676
|
+
poolProbeCache.set(key, { at: Date.now(), value: "unavailable" });
|
|
677
|
+
});
|
|
678
|
+
return pending.value;
|
|
679
|
+
};
|
|
639
680
|
const daemonSyncRunnerPids = new Set();
|
|
640
681
|
let supervisor = null;
|
|
641
682
|
const plan = () => {
|
|
@@ -643,6 +684,83 @@ export async function runDaemon(opts) {
|
|
|
643
684
|
const legacy = legacyUnits();
|
|
644
685
|
const configured = config.hqRoot ?? (env.HQ_ROOT?.trim() || undefined);
|
|
645
686
|
const hqRoot = configured && fs.existsSync(configured) ? configured : undefined;
|
|
687
|
+
let remoteControlPools = { version: 1, pools: [] };
|
|
688
|
+
let remoteControlPoolsError;
|
|
689
|
+
try {
|
|
690
|
+
if (fs.existsSync(paths.remoteControlPoolsPath)) {
|
|
691
|
+
const knownAccounts = hqRoot ? Object.keys(loadLanesConfig(hqRoot).providers.claude.accounts) : undefined;
|
|
692
|
+
remoteControlPools = readRemoteControlPoolsConfig(paths.remoteControlPoolsPath, knownAccounts, home);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
catch (error) {
|
|
696
|
+
remoteControlPoolsError = error.message;
|
|
697
|
+
log("error", `remote-control pool config is invalid: ${remoteControlPoolsError}`);
|
|
698
|
+
}
|
|
699
|
+
const availablePoolSecrets = new Set();
|
|
700
|
+
const unverifiablePoolSecrets = new Set();
|
|
701
|
+
const invalidPoolServiceSessions = new Set();
|
|
702
|
+
const availablePoolFrontends = new Set();
|
|
703
|
+
const unverifiablePoolFrontends = new Set();
|
|
704
|
+
for (const pool of remoteControlPools.pools) {
|
|
705
|
+
const serviceSession = poolProbe(`service-session:${remoteControlPools.serviceSessionId ?? ""}`, () => {
|
|
706
|
+
try {
|
|
707
|
+
if (!remoteControlPools.serviceSessionId)
|
|
708
|
+
return "unavailable";
|
|
709
|
+
assertPersonalServiceSessionCapability(hqRoot ?? home, remoteControlPools.serviceSessionId);
|
|
710
|
+
return "available";
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
return "unavailable";
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
if (serviceSession === "unavailable")
|
|
717
|
+
invalidPoolServiceSessions.add(pool.accountId);
|
|
718
|
+
const secret = serviceSession === "available" ? poolProbe(`secret:${pool.secretName}`, async () => {
|
|
719
|
+
const result = await poolProbeProcess(opts.hqBinary, ["secrets", "--personal", "exists", pool.secretName, "--quiet"], {
|
|
720
|
+
cwd: hqRoot ?? home,
|
|
721
|
+
env: remoteControlPoolSecretsEnv({
|
|
722
|
+
...env,
|
|
723
|
+
...(hqRoot ? { HQ_ROOT: hqRoot } : {}),
|
|
724
|
+
HQ_NO_UPDATE_CHECK: "1",
|
|
725
|
+
}, remoteControlPools.serviceSessionId),
|
|
726
|
+
timeout: 15_000,
|
|
727
|
+
});
|
|
728
|
+
if (result.status === 0)
|
|
729
|
+
return "available";
|
|
730
|
+
return result.status === 1 ? "missing" : "unavailable";
|
|
731
|
+
}) : "unavailable";
|
|
732
|
+
if (secret === "available")
|
|
733
|
+
availablePoolSecrets.add(pool.secretName);
|
|
734
|
+
if (secret === "unavailable" && serviceSession === "available")
|
|
735
|
+
unverifiablePoolSecrets.add(pool.secretName);
|
|
736
|
+
let caValid = false;
|
|
737
|
+
try {
|
|
738
|
+
const caStat = fs.statSync(remoteControlPools.frontendCaPath);
|
|
739
|
+
caValid = caStat.isFile() && fs.accessSync(remoteControlPools.frontendCaPath, fs.constants.R_OK) === undefined;
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
caValid = false;
|
|
743
|
+
}
|
|
744
|
+
const frontend = poolProbe(`frontend:${pool.accountId}:${pool.frontendAddress}`, async () => {
|
|
745
|
+
if (!caValid)
|
|
746
|
+
return "unavailable";
|
|
747
|
+
const result = await poolProbeProcess("curl", [
|
|
748
|
+
"--silent", "--show-error", "--output", "/dev/null", "--write-out", "%{http_code}",
|
|
749
|
+
"--connect-timeout", "1", "--max-time", "2", "--head",
|
|
750
|
+
"--cacert", remoteControlPools.frontendCaPath,
|
|
751
|
+
"--resolve", `api.anthropic.com:443:${pool.frontendAddress}`,
|
|
752
|
+
"https://api.anthropic.com/v1/messages",
|
|
753
|
+
], { timeout: 3_000 });
|
|
754
|
+
const code = Number.parseInt(result.stdout.trim(), 10);
|
|
755
|
+
if (result.status === 0 && code >= 100 && code <= 599)
|
|
756
|
+
return "available";
|
|
757
|
+
return result.error ? "unavailable" : "missing";
|
|
758
|
+
});
|
|
759
|
+
if (frontend === "available")
|
|
760
|
+
availablePoolFrontends.add(pool.accountId);
|
|
761
|
+
if (frontend === "unavailable")
|
|
762
|
+
unverifiablePoolFrontends.add(pool.accountId);
|
|
763
|
+
}
|
|
646
764
|
getStagedHqChildBootstrap();
|
|
647
765
|
const staged = getStagedSyncBundle();
|
|
648
766
|
// Before release, preserve today's selection. Later plans can only use the
|
|
@@ -746,6 +864,14 @@ export async function runDaemon(opts) {
|
|
|
746
864
|
remoteControlName: runnerSessionName(readIfExists(CLAUDE_RUNNER_SCRIPT) ?? ""),
|
|
747
865
|
tmuxPath: findOnPath("tmux", env),
|
|
748
866
|
hostName,
|
|
867
|
+
claudeConfigDir: env.CLAUDE_CONFIG_DIR?.trim() || path.join(home, ".claude"),
|
|
868
|
+
remoteControlPools,
|
|
869
|
+
remoteControlPoolsError,
|
|
870
|
+
availablePoolSecrets,
|
|
871
|
+
unverifiablePoolSecrets,
|
|
872
|
+
invalidPoolServiceSessions,
|
|
873
|
+
availablePoolFrontends,
|
|
874
|
+
unverifiablePoolFrontends,
|
|
749
875
|
});
|
|
750
876
|
if (startupPlanOpen) {
|
|
751
877
|
const replaceablePackageRoots = cliPackageRoot ? [cliPackageRoot] : [];
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `lastAttemptedSlug` is written before each pull so a killed repair resumes
|
|
5
5
|
* after that company instead of starving later journals. `lastRunAttemptedCheckIds`
|
|
6
|
-
* is written only when a repair pass finishes, so
|
|
7
|
-
* can omit company journals this
|
|
8
|
-
*
|
|
6
|
+
* is written only when a repair pass finishes, so doctor JSON
|
|
7
|
+
* (`--json` and `--client-health-report`) can omit company journals this
|
|
8
|
+
* pass did not try — SessionStart hooks, including older ones that only
|
|
9
|
+
* call `hq doctor --json`, otherwise file those leftover rows as unrepaired.
|
|
9
10
|
*/
|
|
10
11
|
import type { CheckResult } from "../types.js";
|
|
11
12
|
export declare const REFRESH_SYNC_PROGRESS_RELATIVE_PATH: string;
|
|
@@ -27,6 +28,6 @@ export declare function writeRefreshSyncLastRun(hqRoot: string, input: {
|
|
|
27
28
|
lastRunAtMs: number;
|
|
28
29
|
}): void;
|
|
29
30
|
export declare function freshAttemptedCompanyJournalCheckIds(hqRoot: string, nowMs: number): ReadonlySet<string> | null;
|
|
30
|
-
/** True when
|
|
31
|
+
/** True when doctor JSON should hide this leftover company journal. */
|
|
31
32
|
export declare function isUnreachedCompanyJournalForClientHealth(result: CheckResult, attemptedCheckIds: ReadonlySet<string> | null): boolean;
|
|
32
33
|
//# sourceMappingURL=refresh-sync-progress.d.ts.map
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `lastAttemptedSlug` is written before each pull so a killed repair resumes
|
|
5
5
|
* after that company instead of starving later journals. `lastRunAttemptedCheckIds`
|
|
6
|
-
* is written only when a repair pass finishes, so
|
|
7
|
-
* can omit company journals this
|
|
8
|
-
*
|
|
6
|
+
* is written only when a repair pass finishes, so doctor JSON
|
|
7
|
+
* (`--json` and `--client-health-report`) can omit company journals this
|
|
8
|
+
* pass did not try — SessionStart hooks, including older ones that only
|
|
9
|
+
* call `hq doctor --json`, otherwise file those leftover rows as unrepaired.
|
|
9
10
|
*/
|
|
10
11
|
import * as fs from "node:fs";
|
|
11
12
|
import * as path from "node:path";
|
|
@@ -93,7 +94,7 @@ export function freshAttemptedCompanyJournalCheckIds(hqRoot, nowMs) {
|
|
|
93
94
|
}
|
|
94
95
|
return new Set(progress.lastRunAttemptedCheckIds);
|
|
95
96
|
}
|
|
96
|
-
/** True when
|
|
97
|
+
/** True when doctor JSON should hide this leftover company journal. */
|
|
97
98
|
export function isUnreachedCompanyJournalForClientHealth(result, attemptedCheckIds) {
|
|
98
99
|
if (!attemptedCheckIds)
|
|
99
100
|
return false;
|
|
@@ -109,11 +109,11 @@ export interface BuildDoctorJsonInput {
|
|
|
109
109
|
* Ordinary hq-core upgrade WARNs and leftover `sync.journal.personal` WARNs
|
|
110
110
|
* are omitted from every JSON document (`--json` and `--client-health-report`),
|
|
111
111
|
* including `summary` tallies: `--fix` cannot apply a release, and the leftover
|
|
112
|
-
* personal shard is not a live sync target.
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
112
|
+
* personal shard is not a live sync target. Company journals this `--fix`
|
|
113
|
+
* pass did not try to refresh are omitted the same way: the repair cap leaves
|
|
114
|
+
* them for a later session, and older hooks that only call `hq doctor --json`
|
|
115
|
+
* treated those leftover WARNs as unrepaired failures. Human `hq doctor`
|
|
116
|
+
* still prints them.
|
|
117
117
|
*/
|
|
118
118
|
clientHealthReport?: boolean;
|
|
119
119
|
/** Clock for the leftover-journal freshness window. Default: `Date.now`. */
|
|
@@ -62,17 +62,14 @@ function summarizeJsonResults(results) {
|
|
|
62
62
|
}
|
|
63
63
|
/** Assemble the machine-readable document from a completed run. */
|
|
64
64
|
export function buildDoctorJson(input) {
|
|
65
|
-
const attemptedRefreshSyncCheckIds = input.
|
|
66
|
-
? freshAttemptedCompanyJournalCheckIds(input.hqRoot, (input.now ?? (() => new Date()))().getTime())
|
|
67
|
-
: null;
|
|
65
|
+
const attemptedRefreshSyncCheckIds = freshAttemptedCompanyJournalCheckIds(input.hqRoot, (input.now ?? (() => new Date()))().getTime());
|
|
68
66
|
const results = [];
|
|
69
67
|
for (const run of input.families) {
|
|
70
68
|
for (const result of run.results) {
|
|
71
69
|
if (omitFromDoctorJson(result)) {
|
|
72
70
|
continue;
|
|
73
71
|
}
|
|
74
|
-
if (
|
|
75
|
-
isUnreachedCompanyJournalForClientHealth(result, attemptedRefreshSyncCheckIds)) {
|
|
72
|
+
if (isUnreachedCompanyJournalForClientHealth(result, attemptedRefreshSyncCheckIds)) {
|
|
76
73
|
continue;
|
|
77
74
|
}
|
|
78
75
|
results.push(toJsonResult(run.family.id, result, input.clientHealthReasonCodesEnabledCheckIds, input.clientHealthCheckStatusEnabledCheckIds));
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `setup:` block of a content pack's package.yaml (sample-pack-in-app-setup
|
|
3
|
+
* US-001, build contract section 1).
|
|
4
|
+
*
|
|
5
|
+
* A pack that declares one can be set up from the HQ app chat: the pack's own
|
|
6
|
+
* bot (`setup.bot.name`, also a worker under `contributes.workers`) asks the
|
|
7
|
+
* questions in order with tap-to-answer suggestions, then runs `setup.run`
|
|
8
|
+
* with the answers. `hq packages install` validates the block and fails naming
|
|
9
|
+
* the field; a pack without the block is untouched.
|
|
10
|
+
*
|
|
11
|
+
* Validation (contract):
|
|
12
|
+
* - `setup.bot.name` matches ^[a-z0-9-]{2,32}$ and appears in contributes.workers
|
|
13
|
+
* - question ids unique, ^[a-z_]+$; options is a list of strings (may be empty)
|
|
14
|
+
* - `run.script` exists in the pack; every `run.args` key is a question id;
|
|
15
|
+
* every `then` entry's first token exists in the pack
|
|
16
|
+
*/
|
|
17
|
+
import type { PackManifest, PackSetup } from "../../types.js";
|
|
18
|
+
export declare const SETUP_BOT_NAME_RE: RegExp;
|
|
19
|
+
export declare const SETUP_QUESTION_ID_RE: RegExp;
|
|
20
|
+
/** A validation failure; `field` is the dotted path of the offending field. */
|
|
21
|
+
export declare class PackSetupError extends Error {
|
|
22
|
+
readonly field: string;
|
|
23
|
+
constructor(field: string, detail: string);
|
|
24
|
+
}
|
|
25
|
+
export interface SetupBlockContext {
|
|
26
|
+
/** Pack root on disk (the payload at install, `core/packages/<name>` later). */
|
|
27
|
+
packDir: string;
|
|
28
|
+
/** `contributes.workers` of the same manifest. */
|
|
29
|
+
workers: readonly string[];
|
|
30
|
+
/** Filesystem seam for tests; defaults to node:fs. */
|
|
31
|
+
existsSync?: (p: string) => boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Validate a raw `setup:` value. Returns the typed block, or undefined when the
|
|
35
|
+
* manifest has no block (a legacy pack). Throws PackSetupError naming the field.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseSetupBlock(raw: unknown, ctx: SetupBlockContext): PackSetup | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* The validated setup block of an installed pack, or undefined when the pack
|
|
40
|
+
* has none or the block is invalid (`hq packages list` prints `setup: no`).
|
|
41
|
+
*/
|
|
42
|
+
export declare function readInstalledPackSetup(manifest: Pick<PackManifest, "setup" | "contributes"> | null | undefined, packDir: string, existsSync?: (p: string) => boolean): PackSetup | undefined;
|
|
43
|
+
//# sourceMappingURL=setup-block.d.ts.map
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `setup:` block of a content pack's package.yaml (sample-pack-in-app-setup
|
|
3
|
+
* US-001, build contract section 1).
|
|
4
|
+
*
|
|
5
|
+
* A pack that declares one can be set up from the HQ app chat: the pack's own
|
|
6
|
+
* bot (`setup.bot.name`, also a worker under `contributes.workers`) asks the
|
|
7
|
+
* questions in order with tap-to-answer suggestions, then runs `setup.run`
|
|
8
|
+
* with the answers. `hq packages install` validates the block and fails naming
|
|
9
|
+
* the field; a pack without the block is untouched.
|
|
10
|
+
*
|
|
11
|
+
* Validation (contract):
|
|
12
|
+
* - `setup.bot.name` matches ^[a-z0-9-]{2,32}$ and appears in contributes.workers
|
|
13
|
+
* - question ids unique, ^[a-z_]+$; options is a list of strings (may be empty)
|
|
14
|
+
* - `run.script` exists in the pack; every `run.args` key is a question id;
|
|
15
|
+
* every `then` entry's first token exists in the pack
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
export const SETUP_BOT_NAME_RE = /^[a-z0-9-]{2,32}$/;
|
|
20
|
+
export const SETUP_QUESTION_ID_RE = /^[a-z_]+$/;
|
|
21
|
+
/** A validation failure; `field` is the dotted path of the offending field. */
|
|
22
|
+
export class PackSetupError extends Error {
|
|
23
|
+
field;
|
|
24
|
+
constructor(field, detail) {
|
|
25
|
+
super(`${field} ${detail}`);
|
|
26
|
+
this.field = field;
|
|
27
|
+
this.name = "PackSetupError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function isMapping(v) {
|
|
31
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
32
|
+
}
|
|
33
|
+
function requireString(value, field) {
|
|
34
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
35
|
+
throw new PackSetupError(field, "must be a non-empty string");
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
function optionalBoolean(value, field, fallback) {
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
return fallback;
|
|
42
|
+
if (typeof value !== "boolean")
|
|
43
|
+
throw new PackSetupError(field, "must be true or false");
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
/** A relative pack path that stays inside the pack root; null when it escapes. */
|
|
47
|
+
function insidePack(packDir, rel) {
|
|
48
|
+
if (path.isAbsolute(rel))
|
|
49
|
+
return null;
|
|
50
|
+
const root = path.resolve(packDir);
|
|
51
|
+
const abs = path.resolve(root, rel);
|
|
52
|
+
if (abs !== root && !abs.startsWith(`${root}${path.sep}`))
|
|
53
|
+
return null;
|
|
54
|
+
return abs;
|
|
55
|
+
}
|
|
56
|
+
function requirePackFile(ctx, rel, field) {
|
|
57
|
+
const exists = ctx.existsSync ?? fs.existsSync;
|
|
58
|
+
const abs = insidePack(ctx.packDir, rel);
|
|
59
|
+
if (!abs)
|
|
60
|
+
throw new PackSetupError(field, `"${rel}" must be a relative path inside the pack`);
|
|
61
|
+
if (!exists(abs))
|
|
62
|
+
throw new PackSetupError(field, `"${rel}" does not exist in the pack`);
|
|
63
|
+
}
|
|
64
|
+
function parseQuestion(raw, index) {
|
|
65
|
+
const field = `setup.questions[${index}]`;
|
|
66
|
+
if (!isMapping(raw))
|
|
67
|
+
throw new PackSetupError(field, "must be a mapping with id and text");
|
|
68
|
+
const id = requireString(raw.id, `${field}.id`);
|
|
69
|
+
if (!SETUP_QUESTION_ID_RE.test(id)) {
|
|
70
|
+
throw new PackSetupError(`${field}.id`, `"${id}" must match ${SETUP_QUESTION_ID_RE.source}`);
|
|
71
|
+
}
|
|
72
|
+
const text = requireString(raw.text, `${field}.text`);
|
|
73
|
+
let options = [];
|
|
74
|
+
if (raw.options !== undefined) {
|
|
75
|
+
if (!Array.isArray(raw.options) || !raw.options.every((o) => typeof o === "string")) {
|
|
76
|
+
throw new PackSetupError(`${field}.options`, "must be a list of strings (it may be empty)");
|
|
77
|
+
}
|
|
78
|
+
options = raw.options;
|
|
79
|
+
}
|
|
80
|
+
const skippable = optionalBoolean(raw.skippable, `${field}.skippable`, false);
|
|
81
|
+
let defaultFrom;
|
|
82
|
+
if (raw.default_from !== undefined)
|
|
83
|
+
defaultFrom = requireString(raw.default_from, `${field}.default_from`);
|
|
84
|
+
let goal;
|
|
85
|
+
if (raw.goal !== undefined)
|
|
86
|
+
goal = requireString(raw.goal, `${field}.goal`);
|
|
87
|
+
return { id, text, options, skippable, ...(defaultFrom ? { default_from: defaultFrom } : {}), ...(goal ? { goal } : {}) };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Validate a raw `setup:` value. Returns the typed block, or undefined when the
|
|
91
|
+
* manifest has no block (a legacy pack). Throws PackSetupError naming the field.
|
|
92
|
+
*/
|
|
93
|
+
export function parseSetupBlock(raw, ctx) {
|
|
94
|
+
if (raw === undefined || raw === null)
|
|
95
|
+
return undefined;
|
|
96
|
+
if (!isMapping(raw))
|
|
97
|
+
throw new PackSetupError("setup", "must be a mapping (bot, questions, run, done)");
|
|
98
|
+
// bot
|
|
99
|
+
if (!isMapping(raw.bot))
|
|
100
|
+
throw new PackSetupError("setup.bot", "must be a mapping with name, display_name, intro");
|
|
101
|
+
const botName = requireString(raw.bot.name, "setup.bot.name");
|
|
102
|
+
if (!SETUP_BOT_NAME_RE.test(botName)) {
|
|
103
|
+
throw new PackSetupError("setup.bot.name", `"${botName}" must match ${SETUP_BOT_NAME_RE.source}`);
|
|
104
|
+
}
|
|
105
|
+
if (!ctx.workers.includes(botName)) {
|
|
106
|
+
throw new PackSetupError("setup.bot.name", `"${botName}" is not listed under contributes.workers` +
|
|
107
|
+
(ctx.workers.length ? ` (declared: ${ctx.workers.join(", ")})` : " (none declared)"));
|
|
108
|
+
}
|
|
109
|
+
const displayName = requireString(raw.bot.display_name, "setup.bot.display_name");
|
|
110
|
+
const intro = requireString(raw.bot.intro, "setup.bot.intro");
|
|
111
|
+
const takeoverSetup = optionalBoolean(raw.takeover_setup, "setup.takeover_setup", false);
|
|
112
|
+
// questions
|
|
113
|
+
if (!Array.isArray(raw.questions) || raw.questions.length === 0) {
|
|
114
|
+
throw new PackSetupError("setup.questions", "must be a non-empty list");
|
|
115
|
+
}
|
|
116
|
+
const questions = raw.questions.map(parseQuestion);
|
|
117
|
+
const ids = new Set();
|
|
118
|
+
questions.forEach((q, i) => {
|
|
119
|
+
if (ids.has(q.id))
|
|
120
|
+
throw new PackSetupError(`setup.questions[${i}].id`, `"${q.id}" is used more than once`);
|
|
121
|
+
ids.add(q.id);
|
|
122
|
+
});
|
|
123
|
+
// run
|
|
124
|
+
if (!isMapping(raw.run))
|
|
125
|
+
throw new PackSetupError("setup.run", "must be a mapping with script (and optional args, then)");
|
|
126
|
+
const script = requireString(raw.run.script, "setup.run.script");
|
|
127
|
+
requirePackFile(ctx, script, "setup.run.script");
|
|
128
|
+
const args = {};
|
|
129
|
+
if (raw.run.args !== undefined) {
|
|
130
|
+
if (!isMapping(raw.run.args))
|
|
131
|
+
throw new PackSetupError("setup.run.args", "must be a mapping of question id to flag");
|
|
132
|
+
for (const [key, flag] of Object.entries(raw.run.args)) {
|
|
133
|
+
if (!ids.has(key)) {
|
|
134
|
+
throw new PackSetupError(`setup.run.args.${key}`, `is not a question id (questions: ${[...ids].join(", ")})`);
|
|
135
|
+
}
|
|
136
|
+
args[key] = requireString(flag, `setup.run.args.${key}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const then = [];
|
|
140
|
+
if (raw.run.then !== undefined) {
|
|
141
|
+
if (!Array.isArray(raw.run.then))
|
|
142
|
+
throw new PackSetupError("setup.run.then", "must be a list of commands");
|
|
143
|
+
raw.run.then.forEach((entry, i) => {
|
|
144
|
+
const field = `setup.run.then[${i}]`;
|
|
145
|
+
const command = requireString(entry, field);
|
|
146
|
+
const first = command.trim().split(/\s+/)[0];
|
|
147
|
+
requirePackFile(ctx, first, field);
|
|
148
|
+
then.push(command.trim());
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// done
|
|
152
|
+
if (!isMapping(raw.done))
|
|
153
|
+
throw new PackSetupError("setup.done", "must be a mapping with message (and optional suggestion)");
|
|
154
|
+
const message = requireString(raw.done.message, "setup.done.message");
|
|
155
|
+
let suggestion;
|
|
156
|
+
if (raw.done.suggestion !== undefined)
|
|
157
|
+
suggestion = requireString(raw.done.suggestion, "setup.done.suggestion");
|
|
158
|
+
// documents
|
|
159
|
+
let documents;
|
|
160
|
+
if (raw.documents !== undefined) {
|
|
161
|
+
if (!Array.isArray(raw.documents) || raw.documents.length === 0 || !raw.documents.every((d) => typeof d === "string" && d.trim())) {
|
|
162
|
+
throw new PackSetupError("setup.documents", "must be a non-empty list of document descriptions");
|
|
163
|
+
}
|
|
164
|
+
documents = raw.documents.map((d) => d.trim());
|
|
165
|
+
}
|
|
166
|
+
// sources
|
|
167
|
+
let sources;
|
|
168
|
+
if (raw.sources !== undefined) {
|
|
169
|
+
if (!Array.isArray(raw.sources) || raw.sources.length === 0) {
|
|
170
|
+
throw new PackSetupError("setup.sources", "must be a non-empty list of { kind, read, informs, options }");
|
|
171
|
+
}
|
|
172
|
+
sources = raw.sources.map((entry, i) => {
|
|
173
|
+
const field = `setup.sources[${i}]`;
|
|
174
|
+
if (!isMapping(entry))
|
|
175
|
+
throw new PackSetupError(field, "must be a mapping with kind, read, informs and options");
|
|
176
|
+
const kind = requireString(entry.kind, `${field}.kind`);
|
|
177
|
+
const read = requireString(entry.read, `${field}.read`);
|
|
178
|
+
if (!Array.isArray(entry.informs) || entry.informs.length === 0 || !entry.informs.every((x) => typeof x === "string")) {
|
|
179
|
+
throw new PackSetupError(`${field}.informs`, "must be a non-empty list of question ids");
|
|
180
|
+
}
|
|
181
|
+
for (const id of entry.informs) {
|
|
182
|
+
if (!ids.has(id))
|
|
183
|
+
throw new PackSetupError(`${field}.informs`, `"${id}" is not a question id (questions: ${[...ids].join(", ")})`);
|
|
184
|
+
}
|
|
185
|
+
if (!Array.isArray(entry.options) || entry.options.length === 0) {
|
|
186
|
+
throw new PackSetupError(`${field}.options`, "must be a non-empty list of { name, domain }");
|
|
187
|
+
}
|
|
188
|
+
const options = entry.options.map((o, j) => {
|
|
189
|
+
const of = `${field}.options[${j}]`;
|
|
190
|
+
if (!isMapping(o))
|
|
191
|
+
throw new PackSetupError(of, "must be a mapping with name and a domain or a tool");
|
|
192
|
+
const name = requireString(o.name, `${of}.name`);
|
|
193
|
+
const available = optionalBoolean(o.available, `${of}.available`, true);
|
|
194
|
+
const opt = { name, ...(available ? {} : { available: false }) };
|
|
195
|
+
for (const key of ["domain", "tool", "connect", "status", "usage", "note"]) {
|
|
196
|
+
if (o[key] !== undefined)
|
|
197
|
+
opt[key] = requireString(o[key], `${of}.${key}`);
|
|
198
|
+
}
|
|
199
|
+
if (opt.tool)
|
|
200
|
+
requirePackFile(ctx, opt.tool, `${of}.tool`);
|
|
201
|
+
if (available && !opt.domain && !opt.tool)
|
|
202
|
+
throw new PackSetupError(of, "needs a domain (HQ integrations) or a tool (pack script), or available: false");
|
|
203
|
+
return opt;
|
|
204
|
+
});
|
|
205
|
+
return { kind, read, informs: [...entry.informs], options };
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
bot: { name: botName, display_name: displayName, intro },
|
|
210
|
+
takeover_setup: takeoverSetup,
|
|
211
|
+
questions,
|
|
212
|
+
run: { script, args, then },
|
|
213
|
+
done: { message, ...(suggestion ? { suggestion } : {}) },
|
|
214
|
+
...(documents ? { documents } : {}),
|
|
215
|
+
...(sources ? { sources } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The validated setup block of an installed pack, or undefined when the pack
|
|
220
|
+
* has none or the block is invalid (`hq packages list` prints `setup: no`).
|
|
221
|
+
*/
|
|
222
|
+
export function readInstalledPackSetup(manifest, packDir, existsSync) {
|
|
223
|
+
if (!manifest || manifest.setup === undefined)
|
|
224
|
+
return undefined;
|
|
225
|
+
try {
|
|
226
|
+
return parseSetupBlock(manifest.setup, {
|
|
227
|
+
packDir,
|
|
228
|
+
workers: manifest.contributes?.workers ?? [],
|
|
229
|
+
...(existsSync ? { existsSync } : {}),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
//# sourceMappingURL=setup-block.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The auto-generated "Pack setup" section of a pack bot's system prompt
|
|
3
|
+
* (build contract section 3). Rendered from the pack's validated `setup:`
|
|
4
|
+
* block: the questions in order with their options, the one-at-a-time rule
|
|
5
|
+
* with the `hq-block` suggestions envelope (contract section 2), the exact
|
|
6
|
+
* command to run with the collected answers, and the done message.
|
|
7
|
+
*/
|
|
8
|
+
import type { PackSetup, PackSetupQuestion } from "../../types.js";
|
|
9
|
+
export declare const SKIP_SUGGESTION = "Skip";
|
|
10
|
+
export declare const HQ_BLOCK_FENCE = "hq-block";
|
|
11
|
+
export interface PackSetupPromptInput {
|
|
12
|
+
/** Manifest name, e.g. hq-pack-sample. */
|
|
13
|
+
packName: string;
|
|
14
|
+
/** Absolute installed pack root (core/packages/<pack>). */
|
|
15
|
+
packRoot: string;
|
|
16
|
+
setup: PackSetup;
|
|
17
|
+
}
|
|
18
|
+
/** The fenced JSON envelope the app draws as buttons (contract section 2). */
|
|
19
|
+
export declare function suggestionsEnvelope(items: readonly string[]): string;
|
|
20
|
+
/** The buttons for one question: its options, plus Skip when it is skippable. */
|
|
21
|
+
export declare function questionSuggestions(q: PackSetupQuestion): string[];
|
|
22
|
+
/** `bash <packRoot>/<script> --firm <company slug> --flag '<answer>' ...` */
|
|
23
|
+
export declare function renderRunCommand(input: PackSetupPromptInput): string;
|
|
24
|
+
/** The `then` commands, each run from the pack root with the same --firm. */
|
|
25
|
+
export declare function renderThenCommands(input: PackSetupPromptInput): string[];
|
|
26
|
+
/**
|
|
27
|
+
* "Their tools": what the bot needs to know to learn from the person's tools
|
|
28
|
+
* before asking. Facts (how each tool connects and is read) and principles;
|
|
29
|
+
* the bot decides how to run the conversation.
|
|
30
|
+
*/
|
|
31
|
+
export declare function renderLearnFirst(setup: PackSetup, packRoot: string): string;
|
|
32
|
+
/** The whole "## Pack setup" section: a brief, not a script. */
|
|
33
|
+
export declare function renderPackSetupSection(input: PackSetupPromptInput): string;
|
|
34
|
+
//# sourceMappingURL=setup-prompt.d.ts.map
|