@hwip/cli 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/NOTICE +11 -0
- package/README.md +1983 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +305 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +39 -0
- package/dist/blocked-prompt-seal.js +92 -0
- package/dist/blocked-prompt.d.ts +62 -0
- package/dist/blocked-prompt.js +518 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +278 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5681 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- package/bin/hwip.js +0 -3
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleInstall = exports.serviceInstallChoice = exports.collectCredentials = exports.unknownOnlyIds = exports.filterAgentsByIds = exports.AGENT_INSTALLERS = exports.firstStderrLine = exports.injectCodexMcpEntry = exports.injectMcpEntry = exports.addAiderReadDirective = exports.writeManagedRule = exports.mergeJsonFile = exports.closingLine = exports.reportDetectedAgents = exports.failedStepCount = exports.failMerge = exports.mergeFailureLines = exports.shouldReauth = void 0;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const os_1 = require("os");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
const readline_1 = require("readline");
|
|
8
|
+
const util_1 = require("util");
|
|
9
|
+
const hwip_hook_js_1 = require("./hwip-hook.js");
|
|
10
|
+
const errors_js_1 = require("./errors.js");
|
|
11
|
+
const notify_failure_js_1 = require("./notify-failure.js");
|
|
12
|
+
const config_js_1 = require("./config.js");
|
|
13
|
+
const login_js_1 = require("./login.js");
|
|
14
|
+
const agents_js_1 = require("./agents.js");
|
|
15
|
+
const tmux_install_js_1 = require("./tmux-install.js");
|
|
16
|
+
const mcp_command_js_1 = require("./mcp-command.js");
|
|
17
|
+
const codex_config_js_1 = require("./codex-config.js");
|
|
18
|
+
const replace_file_js_1 = require("./replace-file.js");
|
|
19
|
+
const jsonc_doc_js_1 = require("./jsonc-doc.js");
|
|
20
|
+
const listener_service_js_1 = require("./listener-service.js");
|
|
21
|
+
const redact_js_1 = require("./redact.js");
|
|
22
|
+
const templates_js_1 = require("./templates.js");
|
|
23
|
+
const args_js_1 = require("./args.js");
|
|
24
|
+
const HOME = (0, os_1.homedir)();
|
|
25
|
+
// Settings files hwip merges into — named once, so a failure names the same file.
|
|
26
|
+
const CURSOR_MCP_FILE = (0, path_1.join)(HOME, '.cursor', 'mcp.json');
|
|
27
|
+
const CURSOR_HOOKS_FILE = (0, path_1.join)(HOME, '.cursor', 'hooks.json');
|
|
28
|
+
const WINDSURF_MCP_FILE = (0, path_1.join)(HOME, '.codeium', 'windsurf', 'mcp_config.json');
|
|
29
|
+
const WINDSURF_HOOKS_FILE = (0, path_1.join)(HOME, '.codeium', 'windsurf', 'hooks.json');
|
|
30
|
+
const GEMINI_SETTINGS_FILE = (0, path_1.join)(HOME, '.gemini', 'settings.json');
|
|
31
|
+
const CODEX_HOOKS_FILE = (0, path_1.join)(HOME, '.codex', 'hooks.json');
|
|
32
|
+
const OPENCODE_CONFIG_FILE = (0, path_1.join)(HOME, '.config', 'opencode', 'opencode.json');
|
|
33
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
34
|
+
// Steps that did not go through — the closing line counts them instead of
|
|
35
|
+
// saying "Done!" over a step that left a file alone.
|
|
36
|
+
let failedSteps = 0;
|
|
37
|
+
const ok = (msg) => console.log(` + ${msg}`);
|
|
38
|
+
const fail = (msg) => {
|
|
39
|
+
failedSteps += 1;
|
|
40
|
+
console.log(` - ${msg}`);
|
|
41
|
+
};
|
|
42
|
+
const skipNote = (msg) => console.log(` · ${msg}`);
|
|
43
|
+
/**
|
|
44
|
+
* True when install should auto-open browser login (ADR 0002): interactive
|
|
45
|
+
* context with no existing credential (--key/env/config all absent).
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* Interactive install, past the `--key` path: open a browser login only when
|
|
49
|
+
* there's nothing saved yet, or the user explicitly asked to re-authenticate
|
|
50
|
+
* with `--relogin`. A plain re-run keeps the saved login untouched.
|
|
51
|
+
*/
|
|
52
|
+
const shouldReauth = (currentKey, relogin) => !currentKey || relogin;
|
|
53
|
+
exports.shouldReauth = shouldReauth;
|
|
54
|
+
const promptInput = (question) => {
|
|
55
|
+
const rl = (0, readline_1.createInterface)({ input: process.stdin, output: process.stdout });
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
rl.question(question, (answer) => {
|
|
58
|
+
rl.close();
|
|
59
|
+
resolve(answer.trim());
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
const writeFile = (filePath, content) => (0, replace_file_js_1.replaceFile)(filePath, content + '\n');
|
|
64
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
65
|
+
const reasonOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
66
|
+
/**
|
|
67
|
+
* An agent's JSON settings, opened for a merge. A missing or empty file is a new
|
|
68
|
+
* one. Comments and trailing commas (JSONC) are read, and kept by the edits that
|
|
69
|
+
* follow. Anything that is not a JSON object throws instead of reading as `{}`:
|
|
70
|
+
* the merge would then write back hwip's entries alone and erase the user's
|
|
71
|
+
* settings — and a file with a syntax error is not edited around either.
|
|
72
|
+
*/
|
|
73
|
+
const openSettings = (filePath) => {
|
|
74
|
+
let text;
|
|
75
|
+
try {
|
|
76
|
+
text = (0, replace_file_js_1.readTextIfExists)(filePath);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (err instanceof replace_file_js_1.NotUtf8Error)
|
|
80
|
+
throw err;
|
|
81
|
+
throw new Error(`cannot read ${filePath} (${reasonOf(err)}), left it unchanged`, { cause: err });
|
|
82
|
+
}
|
|
83
|
+
if (text === null)
|
|
84
|
+
return new jsonc_doc_js_1.JsoncDoc('', false);
|
|
85
|
+
try {
|
|
86
|
+
return new jsonc_doc_js_1.JsoncDoc(text);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
// Not the parser's message: it could quote the text around the error, which
|
|
90
|
+
// would echo part of the user's settings into the terminal.
|
|
91
|
+
const why = err instanceof jsonc_doc_js_1.JsoncSyntaxError
|
|
92
|
+
? 'is not valid JSON (comments and trailing commas are fine, anything else is not)'
|
|
93
|
+
: 'does not hold a JSON object';
|
|
94
|
+
throw new Error(`${filePath} ${why}, left it unchanged`, { cause: err });
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* A merge step that left the user's file alone: why, then the JSON to add by
|
|
99
|
+
* hand. Exported for tests — it is the user's only way forward.
|
|
100
|
+
*/
|
|
101
|
+
const mergeFailureLines = (label, filePath, err, snippet) => err instanceof replace_file_js_1.FileChangedError
|
|
102
|
+
// The file is whole and so is the entry: someone else wrote it meanwhile, and running the command again is the way forward.
|
|
103
|
+
? [`${label} failed: ${reasonOf(err)}`]
|
|
104
|
+
: [
|
|
105
|
+
// The path goes in the instruction: a write error (ENOSPC) names no file.
|
|
106
|
+
`${label} failed: ${reasonOf(err)}. Add this to ${filePath} by hand:`,
|
|
107
|
+
...JSON.stringify(snippet, null, 2).split('\n'),
|
|
108
|
+
];
|
|
109
|
+
exports.mergeFailureLines = mergeFailureLines;
|
|
110
|
+
/** Exported for tests, with `failedStepCount`. */
|
|
111
|
+
const failMerge = (label, filePath, err, snippet) => {
|
|
112
|
+
const [reason, ...json] = (0, exports.mergeFailureLines)(label, filePath, err, snippet);
|
|
113
|
+
fail(reason);
|
|
114
|
+
for (const line of json)
|
|
115
|
+
console.log(` ${line}`);
|
|
116
|
+
};
|
|
117
|
+
exports.failMerge = failMerge;
|
|
118
|
+
const failedStepCount = () => failedSteps;
|
|
119
|
+
exports.failedStepCount = failedStepCount;
|
|
120
|
+
/** The detection list. An agent that is not installed is information, not a
|
|
121
|
+
* failed step — it never counts toward the closing line. Exported for tests. */
|
|
122
|
+
const reportDetectedAgents = (agents) => {
|
|
123
|
+
for (const agent of agents) {
|
|
124
|
+
if (agent.detected)
|
|
125
|
+
ok(agent.name);
|
|
126
|
+
else
|
|
127
|
+
skipNote(`${agent.name} (not found)`);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
exports.reportDetectedAgents = reportDetectedAgents;
|
|
131
|
+
/** The install's last line. Exported for tests. */
|
|
132
|
+
const closingLine = (failed) => {
|
|
133
|
+
if (failed === 0)
|
|
134
|
+
return 'Done! Restart your agents.';
|
|
135
|
+
const steps = failed === 1 ? '1 step needs' : `${failed} steps need`;
|
|
136
|
+
return `Done, but ${steps} you: see the lines marked "-" above. Then restart your agents.`;
|
|
137
|
+
};
|
|
138
|
+
exports.closingLine = closingLine;
|
|
139
|
+
/** An edit that would lose a comment of the user's names the file, like every other refusal. */
|
|
140
|
+
const namingFile = (filePath, edit) => {
|
|
141
|
+
try {
|
|
142
|
+
return edit();
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (err instanceof jsonc_doc_js_1.JsoncEditError)
|
|
146
|
+
throw new Error(`${filePath} ${err.message}, left it unchanged`, { cause: err });
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
/** Exported for tests (installGemini shells out to the real `gemini` binary,
|
|
151
|
+
* so the merge semantics can't be covered through it deterministically). */
|
|
152
|
+
const mergeJsonFile = (filePath, patch) => namingFile(filePath, () => mergeIntoSettings(filePath, patch));
|
|
153
|
+
exports.mergeJsonFile = mergeJsonFile;
|
|
154
|
+
const mergeIntoSettings = (filePath, patch) => {
|
|
155
|
+
const doc = openSettings(filePath);
|
|
156
|
+
const data = doc.data;
|
|
157
|
+
// `hooks` maps event names to arrays of matcher groups (Gemini, Codex) or
|
|
158
|
+
// flat handlers (Cursor, Windsurf), and the user may own entries at BOTH
|
|
159
|
+
// levels (their own events, and their own entries inside an event hwip
|
|
160
|
+
// also uses) — so instead of clobbering the whole object, edit events and,
|
|
161
|
+
// inside each event hwip writes, take out only hwip's own groups and add the
|
|
162
|
+
// new ones (re-runs stay idempotent). Every other byte of the file stays.
|
|
163
|
+
const hooksPatch = isPlainObject(patch.hooks) ? patch.hooks : undefined;
|
|
164
|
+
if (hooksPatch) {
|
|
165
|
+
// A `hooks` hwip cannot merge into would be replaced by hwip's alone.
|
|
166
|
+
if (data.hooks !== undefined && !isPlainObject(data.hooks)) {
|
|
167
|
+
throw new Error(`${filePath} has a "hooks" value that is not an object, left it unchanged`);
|
|
168
|
+
}
|
|
169
|
+
for (const event of Object.keys(hooksPatch)) {
|
|
170
|
+
const prev = data.hooks?.[event];
|
|
171
|
+
if (prev !== undefined && !Array.isArray(prev)) {
|
|
172
|
+
throw new Error(`${filePath} has a "hooks.${event}" value that is not a list, left it unchanged`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
177
|
+
if (key !== 'hooks' || !hooksPatch)
|
|
178
|
+
doc.set([key], value);
|
|
179
|
+
}
|
|
180
|
+
if (hooksPatch) {
|
|
181
|
+
for (const [event, patchGroups] of Object.entries(hooksPatch)) {
|
|
182
|
+
const prev = data.hooks?.[event];
|
|
183
|
+
const add = Array.isArray(patchGroups) ? patchGroups : [];
|
|
184
|
+
if (!Array.isArray(prev)) {
|
|
185
|
+
doc.set(['hooks', event], add);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
// hwip's own groups are replaced where they stand, so a re-run changes
|
|
189
|
+
// nothing and the user's neighbouring entries and comments are not moved.
|
|
190
|
+
const ours = prev.flatMap((group, i) => ((0, templates_js_1.isHwipHookGroup)(group) ? [i] : []));
|
|
191
|
+
add.forEach((group, n) => {
|
|
192
|
+
if (n < ours.length && !(0, util_1.isDeepStrictEqual)(prev[ours[n]], group))
|
|
193
|
+
doc.set(['hooks', event, ours[n]], group);
|
|
194
|
+
});
|
|
195
|
+
for (const i of ours.slice(add.length).reverse())
|
|
196
|
+
doc.remove(['hooks', event, i]);
|
|
197
|
+
for (const group of add.slice(ours.length))
|
|
198
|
+
doc.append(['hooks', event], group);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
(0, replace_file_js_1.replaceFile)(filePath, doc.text(), doc.expected);
|
|
202
|
+
};
|
|
203
|
+
/**
|
|
204
|
+
* Write a Hwip rule into a SHARED agent rule file (Windsurf global_rules.md,
|
|
205
|
+
* Gemini GEMINI.md, Codex AGENTS.md) without clobbering the user's own
|
|
206
|
+
* content. The rule lands inside <!-- HWIP:START/END --> markers; a re-run
|
|
207
|
+
* replaces just that block.
|
|
208
|
+
*/
|
|
209
|
+
/** Exported for tests. */
|
|
210
|
+
const writeManagedRule = (filePath, rule) => {
|
|
211
|
+
// Missing is a new file; anything else that stops the read (no permission, not text) stops the write too.
|
|
212
|
+
const existing = (0, replace_file_js_1.readTextIfExists)(filePath);
|
|
213
|
+
(0, replace_file_js_1.replaceFile)(filePath, (0, templates_js_1.upsertManagedBlock)(existing ?? '', rule), existing);
|
|
214
|
+
};
|
|
215
|
+
exports.writeManagedRule = writeManagedRule;
|
|
216
|
+
/**
|
|
217
|
+
* Add a `read:` entry to ~/.aider.conf.yml so Aider always loads the Hwip
|
|
218
|
+
* conventions file. Idempotent — skips if the path is already referenced.
|
|
219
|
+
* Aider's config is YAML; we do a minimal text-level append to avoid
|
|
220
|
+
* pulling in a YAML dependency (the SDK is zero-dep by design).
|
|
221
|
+
*/
|
|
222
|
+
/** Exported for tests. */
|
|
223
|
+
const addAiderReadDirective = (confPath, conventionsPath) => {
|
|
224
|
+
const existing = (0, replace_file_js_1.readTextIfExists)(confPath);
|
|
225
|
+
const conf = existing ?? '';
|
|
226
|
+
if (conf.includes(conventionsPath))
|
|
227
|
+
return; // already wired up
|
|
228
|
+
const marker = '# Added by Hwip';
|
|
229
|
+
const line = `${marker}\nread: ${conventionsPath}\n`;
|
|
230
|
+
const base = conf.replace(/\n*$/, '');
|
|
231
|
+
(0, replace_file_js_1.replaceFile)(confPath, (base ? `${base}\n\n` : '') + line, existing);
|
|
232
|
+
};
|
|
233
|
+
exports.addAiderReadDirective = addAiderReadDirective;
|
|
234
|
+
// ── Per-Agent Installers ─────────────────────────────────────────
|
|
235
|
+
/** Add the hwip entry under `key` in an MCP registry JSON file, preserving
|
|
236
|
+
* everything else in it. Exported for tests (merge semantics). */
|
|
237
|
+
const injectMcpEntry = (filePath, key, entry) => namingFile(filePath, () => {
|
|
238
|
+
const doc = openSettings(filePath);
|
|
239
|
+
if (!isPlainObject(doc.data[key]))
|
|
240
|
+
doc.set([key], {});
|
|
241
|
+
doc.set([key, 'hwip'], entry);
|
|
242
|
+
(0, replace_file_js_1.replaceFile)(filePath, doc.text(), doc.expected);
|
|
243
|
+
});
|
|
244
|
+
exports.injectMcpEntry = injectMcpEntry;
|
|
245
|
+
/** `[mcp_servers.hwip]` in codex's own TOML config — the one registry that is
|
|
246
|
+
* not JSON. Exported for tests, like `injectMcpEntry`. */
|
|
247
|
+
const injectCodexMcpEntry = (filePath) => {
|
|
248
|
+
const existing = (0, replace_file_js_1.readTextIfExists)(filePath);
|
|
249
|
+
(0, replace_file_js_1.replaceFile)(filePath, (0, codex_config_js_1.upsertHwipMcpTable)(existing ?? '').trimEnd() + '\n', existing);
|
|
250
|
+
};
|
|
251
|
+
exports.injectCodexMcpEntry = injectCodexMcpEntry;
|
|
252
|
+
/** The first non-empty line a failed child process wrote to stderr, cut to one terminal line; undefined when it wrote none. */
|
|
253
|
+
const firstStderrLine = (err) => {
|
|
254
|
+
const raw = err?.stderr;
|
|
255
|
+
const line = raw?.toString().split('\n').map((l) => l.trim()).find(Boolean);
|
|
256
|
+
if (!line)
|
|
257
|
+
return undefined;
|
|
258
|
+
// eslint-disable-next-line no-control-regex -- strips colour codes a child printed to its stderr
|
|
259
|
+
const plain = line.replace(/\x1b\[[0-9;]*m/g, '');
|
|
260
|
+
return plain.length > 160 ? `${plain.slice(0, 157)}...` : plain;
|
|
261
|
+
};
|
|
262
|
+
exports.firstStderrLine = firstStderrLine;
|
|
263
|
+
const installClaude = () => {
|
|
264
|
+
try {
|
|
265
|
+
(0, child_process_1.execSync)('claude plugin marketplace add hwipit/plugin', { stdio: 'pipe' });
|
|
266
|
+
(0, child_process_1.execSync)('claude plugin install hwip@hwip', { stdio: 'pipe' });
|
|
267
|
+
ok('Plugin installed');
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
const why = (0, exports.firstStderrLine)(err);
|
|
271
|
+
fail(`Plugin install failed${why ? ` (${why})` : ''}. Manual:`);
|
|
272
|
+
console.log(' claude plugin marketplace add hwipit/plugin');
|
|
273
|
+
console.log(' claude plugin install hwip@hwip');
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
const installCursor = () => {
|
|
277
|
+
try {
|
|
278
|
+
(0, exports.injectMcpEntry)(CURSOR_MCP_FILE, 'mcpServers', mcp_command_js_1.MCP_SERVERS_ENTRY);
|
|
279
|
+
ok('MCP server added');
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
(0, exports.failMerge)('MCP injection', CURSOR_MCP_FILE, err, { mcpServers: { hwip: mcp_command_js_1.MCP_SERVERS_ENTRY } });
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
(0, exports.mergeJsonFile)(CURSOR_HOOKS_FILE, templates_js_1.CURSOR_HOOKS);
|
|
286
|
+
ok('Stop hook added');
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
(0, exports.failMerge)('Hook install', CURSOR_HOOKS_FILE, err, templates_js_1.CURSOR_HOOKS);
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
writeFile((0, path_1.join)(HOME, '.cursor', 'rules', 'hwip.mdc'), templates_js_1.CURSOR_RULE);
|
|
293
|
+
ok('Rule file added');
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
fail('Rule install failed');
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
const installWindsurf = () => {
|
|
300
|
+
try {
|
|
301
|
+
(0, exports.injectMcpEntry)(WINDSURF_MCP_FILE, 'mcpServers', mcp_command_js_1.MCP_SERVERS_ENTRY);
|
|
302
|
+
ok('MCP server added');
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
(0, exports.failMerge)('MCP injection', WINDSURF_MCP_FILE, err, { mcpServers: { hwip: mcp_command_js_1.MCP_SERVERS_ENTRY } });
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
(0, exports.mergeJsonFile)(WINDSURF_HOOKS_FILE, templates_js_1.WINDSURF_HOOKS);
|
|
309
|
+
ok('Response hook added');
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
(0, exports.failMerge)('Hook install', WINDSURF_HOOKS_FILE, err, templates_js_1.WINDSURF_HOOKS);
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
// Windsurf reads ~/.codeium/windsurf/memories/global_rules.md as always-on
|
|
316
|
+
// global rules. Managed-block append preserves the user's own rules.
|
|
317
|
+
(0, exports.writeManagedRule)((0, path_1.join)(HOME, '.codeium', 'windsurf', 'memories', 'global_rules.md'), templates_js_1.WINDSURF_RULE);
|
|
318
|
+
ok('Rules added to global_rules.md');
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
fail(`Rule install failed: ${reasonOf(err)}. Manual: add hwip rules to ~/.codeium/windsurf/memories/global_rules.md`);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
const installGemini = () => {
|
|
325
|
+
try {
|
|
326
|
+
// Both forms are built in mcp-command.ts; older gemini CLIs only accept
|
|
327
|
+
// the legacy one, so try the modern form first and fall back.
|
|
328
|
+
(0, child_process_1.execSync)(mcp_command_js_1.GEMINI_MCP_ADD, { stdio: 'pipe' });
|
|
329
|
+
ok('MCP server added');
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
try {
|
|
333
|
+
(0, child_process_1.execSync)(mcp_command_js_1.GEMINI_MCP_ADD_LEGACY, { stdio: 'pipe' });
|
|
334
|
+
ok('MCP server added (legacy gemini CLI)');
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
fail(`MCP add failed. Manual: ${mcp_command_js_1.GEMINI_MCP_ADD}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
(0, exports.mergeJsonFile)(GEMINI_SETTINGS_FILE, templates_js_1.GEMINI_HOOKS);
|
|
342
|
+
ok('BeforeAgent + AfterAgent hooks added');
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
(0, exports.failMerge)('Hook install', GEMINI_SETTINGS_FILE, err, templates_js_1.GEMINI_HOOKS);
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
// Gemini CLI loads ~/.gemini/GEMINI.md as global context every prompt.
|
|
349
|
+
(0, exports.writeManagedRule)((0, path_1.join)(HOME, '.gemini', 'GEMINI.md'), templates_js_1.GEMINI_RULE);
|
|
350
|
+
ok('Rules added to GEMINI.md');
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
fail(`Rule install failed: ${reasonOf(err)}. Manual: add hwip rules to ~/.gemini/GEMINI.md`);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
const installCodex = () => {
|
|
357
|
+
try {
|
|
358
|
+
(0, exports.mergeJsonFile)(CODEX_HOOKS_FILE, templates_js_1.CODEX_HOOKS);
|
|
359
|
+
ok('UserPromptSubmit + Stop hooks added');
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
(0, exports.failMerge)('Hook install', CODEX_HOOKS_FILE, err, templates_js_1.CODEX_HOOKS);
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
// Codex CLI loads ~/.codex/AGENTS.md as global instructions.
|
|
366
|
+
(0, exports.writeManagedRule)((0, path_1.join)(HOME, '.codex', 'AGENTS.md'), templates_js_1.CODEX_RULE);
|
|
367
|
+
ok('Rules added to AGENTS.md');
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
fail(`Rule install failed: ${reasonOf(err)}. Manual: add hwip rules to ~/.codex/AGENTS.md`);
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
// The rules above tell codex to call hwip_ask, so it needs the server that
|
|
374
|
+
// provides it: codex has no bundled route to hwip the way pi's extension
|
|
375
|
+
// does. Written straight into the user's config.toml rather than shelled
|
|
376
|
+
// out to `codex mcp add`, which older codex builds do not have.
|
|
377
|
+
(0, exports.injectCodexMcpEntry)((0, path_1.join)(HOME, '.codex', 'config.toml'));
|
|
378
|
+
ok('MCP server registered in config.toml');
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
fail(`MCP install failed: ${reasonOf(err)}. Manual: codex mcp add hwip -- hwip mcp`);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
const installCopilot = () => {
|
|
385
|
+
try {
|
|
386
|
+
writeFile((0, path_1.join)(HOME, '.copilot', 'hooks', 'hwip.json'), templates_js_1.COPILOT_HOOKS);
|
|
387
|
+
ok('Session end hook added');
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
fail('Hook install failed. Manual: add hwip to ~/.copilot/hooks/');
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
// Copilot CLI loads ~/.copilot/instructions/*.instructions.md globally.
|
|
394
|
+
// A dedicated file means no merge needed — overwrite is safe.
|
|
395
|
+
writeFile((0, path_1.join)(HOME, '.copilot', 'instructions', 'hwip.instructions.md'), templates_js_1.COPILOT_RULE);
|
|
396
|
+
ok('Rule file added');
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
fail('Rule install failed. Manual: add hwip rules to ~/.copilot/instructions/');
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
const installCline = () => {
|
|
403
|
+
try {
|
|
404
|
+
writeFile((0, path_1.join)(HOME, '.cline', 'rules', 'hwip.md'), templates_js_1.CLINE_RULE);
|
|
405
|
+
ok('Rule file added');
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
fail('Rule install failed. Manual: add hwip to ~/.cline/rules/');
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
const installAider = () => {
|
|
412
|
+
// Aider has no hooks; rules reach it via a conventions file loaded by the
|
|
413
|
+
// `read:` directive in ~/.aider.conf.yml. We keep the conventions file in
|
|
414
|
+
// ~/.hwip/ (our own dir — no conflict) and just wire the read directive.
|
|
415
|
+
const conventionsPath = (0, path_1.join)(HOME, '.hwip', 'aider-conventions.md');
|
|
416
|
+
try {
|
|
417
|
+
writeFile(conventionsPath, templates_js_1.AIDER_RULE);
|
|
418
|
+
ok('Conventions file added');
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
fail('Conventions install failed. Manual: save hwip rules somewhere readable');
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
(0, exports.addAiderReadDirective)((0, path_1.join)(HOME, '.aider.conf.yml'), conventionsPath);
|
|
426
|
+
ok('read: directive added to ~/.aider.conf.yml');
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
fail(`Config wiring failed: ${reasonOf(err)}. Manual: add "read: ${conventionsPath}" to ~/.aider.conf.yml`);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
const installPi = () => {
|
|
433
|
+
try {
|
|
434
|
+
// Drop-in auto-load; full-file overwrite is the idempotency model
|
|
435
|
+
// (the header comment is the ownership marker). No MCP — pi has none;
|
|
436
|
+
// PI_RULE maps the hwip_* tools to the CLI instead.
|
|
437
|
+
writeFile((0, path_1.join)(HOME, '.pi', 'agent', 'extensions', 'hwip.ts'), templates_js_1.PI_EXTENSION);
|
|
438
|
+
ok('Extension added (settle notify + remote detection)');
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
fail('Extension install failed. Manual: save the hwip extension to ~/.pi/agent/extensions/hwip.ts');
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
// Pi loads ~/.pi/agent/AGENTS.md as global context.
|
|
445
|
+
(0, exports.writeManagedRule)((0, path_1.join)(HOME, '.pi', 'agent', 'AGENTS.md'), templates_js_1.PI_RULE);
|
|
446
|
+
ok('Rules added to AGENTS.md');
|
|
447
|
+
}
|
|
448
|
+
catch (err) {
|
|
449
|
+
fail(`Rule install failed: ${reasonOf(err)}. Manual: add hwip rules to ~/.pi/agent/AGENTS.md`);
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
const installOpencode = () => {
|
|
453
|
+
try {
|
|
454
|
+
(0, exports.injectMcpEntry)(OPENCODE_CONFIG_FILE, 'mcp', mcp_command_js_1.OPENCODE_MCP_ENTRY);
|
|
455
|
+
ok('MCP server added');
|
|
456
|
+
}
|
|
457
|
+
catch (err) {
|
|
458
|
+
(0, exports.failMerge)('MCP injection', OPENCODE_CONFIG_FILE, err, { mcp: { hwip: mcp_command_js_1.OPENCODE_MCP_ENTRY } });
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
writeFile((0, path_1.join)(HOME, '.config', 'opencode', 'plugins', 'hwip.ts'), templates_js_1.OPENCODE_PLUGIN);
|
|
462
|
+
ok('Idle-notify plugin added');
|
|
463
|
+
// opencode 2.x runs plugins inside its background server, which loads them
|
|
464
|
+
// at start: one already running keeps going without this file until it
|
|
465
|
+
// reloads (measured on 2.0.24). Not done for the user — a reload touches
|
|
466
|
+
// every session that server is running.
|
|
467
|
+
skipNote('On opencode 2.x, run `opencode reload` so an opencode already running loads it');
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
fail(`Plugin install failed: ${reasonOf(err)}. Manual: save the hwip plugin to ~/.config/opencode/plugins/hwip.ts`);
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
(0, exports.writeManagedRule)((0, path_1.join)(HOME, '.config', 'opencode', 'AGENTS.md'), templates_js_1.OPENCODE_RULE);
|
|
474
|
+
ok('Rules added to AGENTS.md');
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
fail(`Rule install failed: ${reasonOf(err)}. Manual: add hwip rules to ~/.config/opencode/AGENTS.md`);
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
/** Exported for tests — each runs against the HOME the module was loaded with. */
|
|
481
|
+
exports.AGENT_INSTALLERS = {
|
|
482
|
+
claude: installClaude,
|
|
483
|
+
cursor: installCursor,
|
|
484
|
+
windsurf: installWindsurf,
|
|
485
|
+
gemini: installGemini,
|
|
486
|
+
codex: installCodex,
|
|
487
|
+
copilot: installCopilot,
|
|
488
|
+
cline: installCline,
|
|
489
|
+
aider: installAider,
|
|
490
|
+
pi: installPi,
|
|
491
|
+
opencode: installOpencode,
|
|
492
|
+
};
|
|
493
|
+
// One-line summary of what each agent's installer does — shown in the
|
|
494
|
+
// interactive plan before anything is written.
|
|
495
|
+
const AGENT_PLAN_LABELS = {
|
|
496
|
+
claude: 'Claude Code — install plugin',
|
|
497
|
+
cursor: 'Cursor — MCP + hooks + rules',
|
|
498
|
+
windsurf: 'Windsurf — MCP + hooks + rules',
|
|
499
|
+
gemini: 'Gemini CLI — MCP + hooks + rules',
|
|
500
|
+
codex: 'Codex CLI — MCP + hooks + rules',
|
|
501
|
+
copilot: 'Copilot CLI — hooks + rules',
|
|
502
|
+
cline: 'Cline — rules',
|
|
503
|
+
aider: 'Aider — conventions',
|
|
504
|
+
pi: 'Pi — extension + rules',
|
|
505
|
+
opencode: 'OpenCode — MCP + plugin + rules',
|
|
506
|
+
};
|
|
507
|
+
// ── Agent selection ──────────────────────────────────────────────
|
|
508
|
+
/**
|
|
509
|
+
* Interactive agent picker — an @inquirer/prompts checkbox (arrow keys
|
|
510
|
+
* to move, space to toggle, enter to confirm). Every agent starts
|
|
511
|
+
* checked, so a bare Enter installs for all. Returns the chosen Agent[].
|
|
512
|
+
*
|
|
513
|
+
* Dynamic import keeps @inquirer/prompts (ESM) loadable from this
|
|
514
|
+
* CommonJS build, and means the dependency is only touched on the
|
|
515
|
+
* interactive path — `notify` / `list` / scripted `install --only`
|
|
516
|
+
* never load it.
|
|
517
|
+
*/
|
|
518
|
+
const pickAgentsInteractive = async (detected) => {
|
|
519
|
+
const { checkbox } = await import('@inquirer/prompts');
|
|
520
|
+
const picked = await checkbox({
|
|
521
|
+
message: 'Install Hwip for which agents? (space to toggle, enter to confirm)',
|
|
522
|
+
choices: detected.map((agent) => ({
|
|
523
|
+
name: AGENT_PLAN_LABELS[agent.id] ?? agent.name,
|
|
524
|
+
value: agent.id,
|
|
525
|
+
checked: true,
|
|
526
|
+
})),
|
|
527
|
+
loop: false,
|
|
528
|
+
});
|
|
529
|
+
return detected.filter((a) => picked.includes(a.id));
|
|
530
|
+
};
|
|
531
|
+
/**
|
|
532
|
+
* Resolve agents from a non-interactive `--only cursor,gemini` flag.
|
|
533
|
+
* Matches on agent id (`cc` is accepted for `claude`); ids that are not
|
|
534
|
+
* detected are dropped here, and `unknownOnlyIds` is what flags a typo.
|
|
535
|
+
* Exported for unit testing.
|
|
536
|
+
*/
|
|
537
|
+
const filterAgentsByIds = (detected, only) => {
|
|
538
|
+
const ids = new Set(onlyIds(only));
|
|
539
|
+
return detected.filter((a) => ids.has(a.id));
|
|
540
|
+
};
|
|
541
|
+
exports.filterAgentsByIds = filterAgentsByIds;
|
|
542
|
+
/** `hwip cc` is how the landing page names Claude Code, so `--only cc` means `claude`. */
|
|
543
|
+
const ONLY_ALIASES = { cc: 'claude' };
|
|
544
|
+
const onlyIds = (only) => only.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean).map((id) => ONLY_ALIASES[id] ?? id);
|
|
545
|
+
/** The `--only` ids that name no agent hwip knows (detected or not): a typo, not "nothing to install". Exported for tests. */
|
|
546
|
+
const unknownOnlyIds = (all, only) => {
|
|
547
|
+
const known = new Set(all.map((a) => a.id));
|
|
548
|
+
return onlyIds(only).filter((id) => !known.has(id));
|
|
549
|
+
};
|
|
550
|
+
exports.unknownOnlyIds = unknownOnlyIds;
|
|
551
|
+
// ── Test Connection ──────────────────────────────────────────────
|
|
552
|
+
const testConnection = async (apiKey, baseUrl) => {
|
|
553
|
+
try {
|
|
554
|
+
const hook = new hwip_hook_js_1.HwipHook({ apiKey, ...(baseUrl && { baseUrl }) });
|
|
555
|
+
// A real push like any other: a good one clears a stale failure record (a
|
|
556
|
+
// re-login after a revoked key), a bad one replaces it.
|
|
557
|
+
const result = await (0, notify_failure_js_1.withNotifyFailureRecord)(() => hook.notify({
|
|
558
|
+
title: 'Hwip Setup',
|
|
559
|
+
body: `Connected successfully (v${config_js_1.VERSION})`,
|
|
560
|
+
}));
|
|
561
|
+
ok(`Test push sent: ${result.pushId}`);
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
fail(`Test failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
/**
|
|
570
|
+
* Why the API refused `apiKey`, or undefined when it did not — a read, not a
|
|
571
|
+
* push, so it can run before the key is saved. Only a 401 is a refusal: a
|
|
572
|
+
* 403 is a quota or a scope, and a network failure says nothing about the key.
|
|
573
|
+
*/
|
|
574
|
+
const keyRejection = async (apiKey, baseUrl) => {
|
|
575
|
+
try {
|
|
576
|
+
// Short: an offline box still installs, and the test push waits its own timeout after.
|
|
577
|
+
await new hwip_hook_js_1.HwipHook({ apiKey, timeout: 5000, ...(baseUrl && { baseUrl }) }).list({ limit: 1 });
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
catch (err) {
|
|
581
|
+
return err instanceof errors_js_1.HwipError && err.status === 401 ? err.message : undefined;
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
/**
|
|
585
|
+
* Resolve API key + hook for install. Priority: --key/env/config (non-interactive)
|
|
586
|
+
* → a saved login is reused untouched on re-run → otherwise a brand-new
|
|
587
|
+
* interactive install opens browser login (ADR 0002), falling back to manual
|
|
588
|
+
* paste when headless. wsUrl/deviceId from a login are persisted by
|
|
589
|
+
* runLoginFlow and re-read at config-save time.
|
|
590
|
+
*/
|
|
591
|
+
/** Exported for tests. */
|
|
592
|
+
const collectCredentials = async (args, installArgs, nonInteractive, existing) => {
|
|
593
|
+
if (nonInteractive) {
|
|
594
|
+
// The saved key is checked against, and saved with, the address given here: one from the environment is not where it belongs (C129).
|
|
595
|
+
const envBase = installArgs['base-url'] ? undefined : (0, config_js_1.resolvedEnv)('HWIP_BASE_URL');
|
|
596
|
+
const refusal = envBase && (0, config_js_1.keyIsFromFile)(installArgs.key, existing) ? (0, config_js_1.fileKeyBaseRefusal)(envBase, existing) : null;
|
|
597
|
+
if (refusal)
|
|
598
|
+
throw new Error(refusal);
|
|
599
|
+
// Saved only after it passes the same checks `resolveBaseUrl` applies: a user name or password
|
|
600
|
+
// in the address would otherwise sit in config.json and stop every later command (C150).
|
|
601
|
+
const rawBase = installArgs['base-url'] || (0, config_js_1.resolvedEnv)('HWIP_BASE_URL') || existing.baseUrl;
|
|
602
|
+
const baseUrl = typeof rawBase === 'string'
|
|
603
|
+
? (0, config_js_1.checkBaseUrl)(rawBase, installArgs['base-url'] ? '--base-url' : envBase ? 'HWIP_BASE_URL' : `"baseUrl" in ${config_js_1.CONFIG_FILE}`)
|
|
604
|
+
: rawBase;
|
|
605
|
+
return {
|
|
606
|
+
apiKey: installArgs.key || (0, config_js_1.resolvedEnv)('HWIP_API_KEY') || existing.apiKey,
|
|
607
|
+
hookId: installArgs.hook === 'none' ? undefined : (installArgs.hook || (0, config_js_1.resolvedEnv)('HWIP_HOOK_ID') || existing.hookId),
|
|
608
|
+
baseUrl,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
console.log('');
|
|
612
|
+
const currentKey = (0, config_js_1.resolvedEnv)('HWIP_API_KEY') || existing.apiKey;
|
|
613
|
+
// Re-run with a saved login: reuse it silently and move on to agent wiring.
|
|
614
|
+
// Switching accounts is `hwip login` or `hwip install --relogin`.
|
|
615
|
+
if (!(0, exports.shouldReauth)(currentKey, (0, args_js_1.flagOn)(args, 'relogin'))) {
|
|
616
|
+
console.log(` Using saved login (${(0, redact_js_1.maskKey)(currentKey)}). Run \`hwip install --relogin\` to switch account.`);
|
|
617
|
+
return {
|
|
618
|
+
apiKey: currentKey,
|
|
619
|
+
hookId: (0, config_js_1.resolvedEnv)('HWIP_HOOK_ID') || existing.hookId,
|
|
620
|
+
baseUrl: existing.baseUrl,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
const result = await (0, login_js_1.runLoginFlow)({
|
|
624
|
+
webUrl: (0, login_js_1.resolveWebUrl)(args['web-url']),
|
|
625
|
+
timeoutSec: (0, login_js_1.resolveTimeoutSec)(args.timeout),
|
|
626
|
+
allowHosts: (0, login_js_1.resolveAllowHosts)(args['allow-host']),
|
|
627
|
+
pasteNext: true,
|
|
628
|
+
});
|
|
629
|
+
if (result && 'apiKey' in result) {
|
|
630
|
+
return { apiKey: result.apiKey, hookId: result.hookId, baseUrl: result.baseUrl };
|
|
631
|
+
}
|
|
632
|
+
// headless / timeout / skipped → manual paste (a key typed during the wait counts)
|
|
633
|
+
const apiKey = (result?.pastedKey || await promptInput(' API Key (from app > Settings > API Keys): ')) || undefined;
|
|
634
|
+
const hookInput = await promptInput(' Hook ID (optional, for hwip_ask): ');
|
|
635
|
+
return { apiKey, hookId: hookInput || undefined, baseUrl: existing.baseUrl };
|
|
636
|
+
};
|
|
637
|
+
exports.collectCredentials = collectCredentials;
|
|
638
|
+
// ── Login-time service ───────────────────────────────────────────
|
|
639
|
+
/**
|
|
640
|
+
* Whether this install should register the service that starts the listener
|
|
641
|
+
* at login (a LaunchAgent on macOS, a systemd user unit on Linux).
|
|
642
|
+
*
|
|
643
|
+
* Interactive installs offer it and default to yes: an empty phone picker
|
|
644
|
+
* after every reboot is precisely the kind of thing a default should fix.
|
|
645
|
+
* Scripted installs (`--key`, CI, provisioning) get it only when asked —
|
|
646
|
+
* planting a background job on a machine whose operator only wanted an API
|
|
647
|
+
* key written is a surprise, not a convenience.
|
|
648
|
+
*/
|
|
649
|
+
const serviceInstallChoice = (args, nonInteractive) => {
|
|
650
|
+
if ((0, args_js_1.flagOn)(args, 'no-service'))
|
|
651
|
+
return 'no';
|
|
652
|
+
if (!(0, listener_service_js_1.serviceSupported)())
|
|
653
|
+
return 'no';
|
|
654
|
+
if ((0, args_js_1.flagOn)(args, 'service'))
|
|
655
|
+
return 'yes';
|
|
656
|
+
return nonInteractive ? 'no' : 'ask';
|
|
657
|
+
};
|
|
658
|
+
exports.serviceInstallChoice = serviceInstallChoice;
|
|
659
|
+
/** Run the choice: ask when it should be asked, then install if wanted. */
|
|
660
|
+
const applyServiceChoice = async (choice) => {
|
|
661
|
+
if (choice === 'no')
|
|
662
|
+
return;
|
|
663
|
+
let wanted = choice === 'yes';
|
|
664
|
+
if (choice === 'ask') {
|
|
665
|
+
try {
|
|
666
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
667
|
+
wanted = await confirm({
|
|
668
|
+
message: 'Start the listener at every login, so the phone sees this machine after a reboot? (enter to confirm)',
|
|
669
|
+
default: true,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
// Ctrl-C or no TTY — an unanswered question is not a yes.
|
|
674
|
+
wanted = false;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (!wanted) {
|
|
678
|
+
skipNote('login-time service not installed (add it later: hwip listener --install-service)');
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const result = await (0, listener_service_js_1.installService)();
|
|
682
|
+
for (const note of result.notes)
|
|
683
|
+
ok(note);
|
|
684
|
+
if (result.ok)
|
|
685
|
+
ok('listener starts at every login');
|
|
686
|
+
else
|
|
687
|
+
fail(`login-time service: ${result.reason}`);
|
|
688
|
+
};
|
|
689
|
+
// ── Main Install Flow ────────────────────────────────────────────
|
|
690
|
+
const handleInstall = async (args) => {
|
|
691
|
+
const installArgs = {
|
|
692
|
+
key: args.key,
|
|
693
|
+
hook: args.hook,
|
|
694
|
+
'base-url': args['base-url'],
|
|
695
|
+
};
|
|
696
|
+
const nonInteractive = !!(installArgs.key || installArgs.hook || installArgs['base-url']);
|
|
697
|
+
failedSteps = 0;
|
|
698
|
+
console.log(`\n Hwip v${config_js_1.VERSION}\n`);
|
|
699
|
+
// A config.json hwip cannot read as JSON would be replaced by the new key alone: say so before any prompt, login or key check.
|
|
700
|
+
const blocked = (0, config_js_1.configWriteProblem)();
|
|
701
|
+
if (blocked) {
|
|
702
|
+
console.error(` Error: ${blocked}\n`);
|
|
703
|
+
return 1;
|
|
704
|
+
}
|
|
705
|
+
// 1. Detect agents
|
|
706
|
+
console.log(' Detecting agents...');
|
|
707
|
+
const agents = (0, agents_js_1.detectAgents)();
|
|
708
|
+
const detected = agents.filter((a) => a.detected);
|
|
709
|
+
(0, exports.reportDetectedAgents)(agents);
|
|
710
|
+
if (detected.length === 0) {
|
|
711
|
+
console.log('\n No supported agents found. Config will still be saved.\n');
|
|
712
|
+
}
|
|
713
|
+
// 2. Choose which agents to install for — asked up front so the user
|
|
714
|
+
// sees the choice before being walked through credential prompts.
|
|
715
|
+
let selected = detected;
|
|
716
|
+
const onlyArg = args.only?.trim();
|
|
717
|
+
if (detected.length > 0) {
|
|
718
|
+
if (onlyArg) {
|
|
719
|
+
const unknown = (0, exports.unknownOnlyIds)(agents, onlyArg);
|
|
720
|
+
if (unknown.length > 0) {
|
|
721
|
+
console.error(`\n Error: --only names no agent hwip knows: ${unknown.join(', ')}. Valid ids: ${agents.map((a) => a.id).join(', ')} (cc = claude).\n`);
|
|
722
|
+
return 2;
|
|
723
|
+
}
|
|
724
|
+
// Non-interactive or scripted: --only cursor,gemini
|
|
725
|
+
selected = (0, exports.filterAgentsByIds)(detected, onlyArg);
|
|
726
|
+
console.log(`\n --only ${onlyArg} → ${selected.map((a) => a.name).join(', ') || '(no match)'}`);
|
|
727
|
+
}
|
|
728
|
+
else if (nonInteractive) {
|
|
729
|
+
// Scripted run with no --only: keep the all-detected default
|
|
730
|
+
selected = detected;
|
|
731
|
+
}
|
|
732
|
+
else if (process.stdin.isTTY !== true) {
|
|
733
|
+
// The picker needs a terminal; without one it used to print "Cancelled." and exit 0.
|
|
734
|
+
console.error('\n Error: choosing agents needs a terminal. For a scripted install pass --key (and --only <agents>).\n');
|
|
735
|
+
return 2;
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
try {
|
|
739
|
+
selected = await pickAgentsInteractive(detected);
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
// Ctrl-C in the picker (or no TTY) — treat as a clean cancel.
|
|
743
|
+
console.log('\n Cancelled.\n');
|
|
744
|
+
return 0;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
// 3. Collect credentials (browser login auto-triggers for brand-new installs)
|
|
749
|
+
const existing = (0, config_js_1.loadConfig)();
|
|
750
|
+
let credentials;
|
|
751
|
+
try {
|
|
752
|
+
credentials = await (0, exports.collectCredentials)(args, installArgs, nonInteractive, existing);
|
|
753
|
+
}
|
|
754
|
+
catch (err) {
|
|
755
|
+
console.error(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
756
|
+
return 1;
|
|
757
|
+
}
|
|
758
|
+
const { apiKey, hookId, baseUrl } = credentials;
|
|
759
|
+
if (!apiKey) {
|
|
760
|
+
console.error('\n Error: API key is required.\n');
|
|
761
|
+
return 1;
|
|
762
|
+
}
|
|
763
|
+
// A key the API refuses is not saved: every push after it would fail.
|
|
764
|
+
const rejected = await keyRejection(apiKey, baseUrl);
|
|
765
|
+
if (rejected) {
|
|
766
|
+
console.error(`\n Error: the API rejected this key (${rejected}). Nothing was saved.`);
|
|
767
|
+
console.error(' Check the key in the Hwip app (Settings > API Keys), or sign in again: hwip install --relogin\n');
|
|
768
|
+
return 3;
|
|
769
|
+
}
|
|
770
|
+
// 4. Show the resolved plan before touching anything (interactive only).
|
|
771
|
+
if (!nonInteractive) {
|
|
772
|
+
console.log('\n Will do:');
|
|
773
|
+
console.log(` • Save config to ${config_js_1.CONFIG_FILE}`);
|
|
774
|
+
for (const agent of selected) {
|
|
775
|
+
console.log(` • ${AGENT_PLAN_LABELS[agent.id] ?? `Install for ${agent.name}`}`);
|
|
776
|
+
}
|
|
777
|
+
if (selected.length === 0) {
|
|
778
|
+
console.log(' (no agents selected — only the config file will be saved)');
|
|
779
|
+
}
|
|
780
|
+
console.log(' • Check tmux (`hwip cc` runs agents inside it)');
|
|
781
|
+
if ((0, exports.serviceInstallChoice)(args, nonInteractive) !== 'no') {
|
|
782
|
+
console.log(' • Offer to start the listener at every login');
|
|
783
|
+
}
|
|
784
|
+
console.log(' • Test connection');
|
|
785
|
+
}
|
|
786
|
+
// 5. Save config — merge over the latest on-disk config (re-read, since a
|
|
787
|
+
// login in step 3 may have written wsUrl/deviceId). hookId set or cleared.
|
|
788
|
+
console.log('');
|
|
789
|
+
const config = {
|
|
790
|
+
...(0, config_js_1.loadConfig)(),
|
|
791
|
+
apiKey,
|
|
792
|
+
...(baseUrl && { baseUrl }),
|
|
793
|
+
};
|
|
794
|
+
if (hookId)
|
|
795
|
+
config.hookId = hookId;
|
|
796
|
+
else
|
|
797
|
+
delete config.hookId;
|
|
798
|
+
(0, config_js_1.saveConfig)(config);
|
|
799
|
+
ok(`Config saved to ${config_js_1.CONFIG_FILE}`);
|
|
800
|
+
// 6. Install for the selected agents only
|
|
801
|
+
for (const agent of selected) {
|
|
802
|
+
console.log(`\n Installing for ${agent.name}...`);
|
|
803
|
+
const installer = exports.AGENT_INSTALLERS[agent.id];
|
|
804
|
+
if (installer)
|
|
805
|
+
installer();
|
|
806
|
+
}
|
|
807
|
+
// 7. tmux — `hwip cc` is tmux or nothing, so check before claiming success.
|
|
808
|
+
console.log('\n Checking tmux...');
|
|
809
|
+
await (0, tmux_install_js_1.ensureTmux)({
|
|
810
|
+
plan: (0, tmux_install_js_1.planTmuxInstall)({ resolve: agents_js_1.resolveCommand, platform: process.platform }),
|
|
811
|
+
nonInteractive,
|
|
812
|
+
askConfirm: async (message) => {
|
|
813
|
+
try {
|
|
814
|
+
const { confirm } = await import('@inquirer/prompts');
|
|
815
|
+
return await confirm({ message, default: true });
|
|
816
|
+
}
|
|
817
|
+
catch {
|
|
818
|
+
// Ctrl-C or no TTY — an unanswered question is not a yes.
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
},
|
|
822
|
+
run: (argv) => { (0, child_process_1.execFileSync)(argv[0], argv.slice(1), { stdio: 'inherit' }); },
|
|
823
|
+
// A scripted install (CI, a container that only needs the hooks) does not
|
|
824
|
+
// fail for want of tmux: only `hwip cc` needs it, and the line says so.
|
|
825
|
+
report: { ok, fail: nonInteractive ? skipNote : fail, note: skipNote },
|
|
826
|
+
});
|
|
827
|
+
// 8. Login-time service — the only thing that makes the phone see this
|
|
828
|
+
// machine after a reboot without a terminal being opened first.
|
|
829
|
+
await applyServiceChoice((0, exports.serviceInstallChoice)(args, nonInteractive));
|
|
830
|
+
// 9. Test connection
|
|
831
|
+
console.log('\n Testing connection...');
|
|
832
|
+
await testConnection(apiKey, baseUrl);
|
|
833
|
+
console.log(`\n ${(0, exports.closingLine)(failedSteps)}\n`);
|
|
834
|
+
// The single most-reported surprise: pushes are not scoped to `hwip cc`.
|
|
835
|
+
// Hooks fire for every session of every configured agent, so say it here —
|
|
836
|
+
// at the moment the hooks get installed — together with the volume dials.
|
|
837
|
+
console.log(' Notifications now fire for EVERY session of each agent above');
|
|
838
|
+
console.log(' (not only sessions launched with `hwip cc`).');
|
|
839
|
+
console.log('');
|
|
840
|
+
console.log(' In Claude Code the default is QUIET: you get pushed when the agent');
|
|
841
|
+
console.log(' asks you something and when a session finishes, not on every turn.');
|
|
842
|
+
console.log(' Dial it any time:');
|
|
843
|
+
console.log(' /hwip-mode normal push on every turn that did real work');
|
|
844
|
+
console.log(' /hwip-mode loud push on every turn');
|
|
845
|
+
console.log(' /hwip-mode mute full silence, current project');
|
|
846
|
+
console.log(' /hwip-mode show what is in effect\n');
|
|
847
|
+
// A step marked "-" (the test push included) is not a finished install.
|
|
848
|
+
return failedSteps > 0 ? 1 : 0;
|
|
849
|
+
};
|
|
850
|
+
exports.handleInstall = handleInstall;
|