miki-moni 0.3.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 +21 -0
- package/README.md +283 -0
- package/README.zh-CN.md +275 -0
- package/README.zh-TW.md +275 -0
- package/bin/miki.mjs +49 -0
- package/dist/web/assets/favicon-DFpLtP36.svg +13 -0
- package/dist/web/assets/index--89DkyV1.css +1 -0
- package/dist/web/assets/index-CyPlxvOn.js +64 -0
- package/dist/web/index.html +20 -0
- package/dist/web/pair-info.html +138 -0
- package/dist/web-phone/assets/app-CyQWCdKZ.js +64 -0
- package/dist/web-phone/assets/index-D5BUh7Uf.js +1 -0
- package/dist/web-phone/assets/index-D8vY_9ld.css +1 -0
- package/dist/web-phone/index.html +20 -0
- package/hooks/miki-emit.ps1 +56 -0
- package/package.json +89 -0
- package/shared/i18n.ts +915 -0
- package/src/cli/i18n-cli.ts +149 -0
- package/src/cli/miki.ts +168 -0
- package/src/cli/pair.ts +534 -0
- package/src/cli/prompt.ts +6 -0
- package/src/cli/pushable-iter.ts +45 -0
- package/src/cli/setup-self-host.ts +292 -0
- package/src/cli/setup-wizard.ts +130 -0
- package/src/cli/wrap.ts +742 -0
- package/src/config.ts +121 -0
- package/src/crypto.ts +66 -0
- package/src/data-dir.ts +31 -0
- package/src/ext-registry.ts +47 -0
- package/src/hook-handler.ts +86 -0
- package/src/index.ts +279 -0
- package/src/install-hooks.ts +107 -0
- package/src/notifier.ts +21 -0
- package/src/pairing.ts +100 -0
- package/src/protocol-ext.ts +46 -0
- package/src/relay-client.ts +468 -0
- package/src/relay-protocol.ts +57 -0
- package/src/server.ts +1134 -0
- package/src/session-resolver.ts +437 -0
- package/src/session-store.ts +131 -0
- package/src/types.ts +33 -0
- package/src/vscode-bridge.ts +407 -0
- package/src/wrap-process.ts +183 -0
- package/tools/tray.ps1 +286 -0
- package/worker/package.json +24 -0
- package/worker/src/daemon-relay.ts +348 -0
- package/worker/src/env.ts +11 -0
- package/worker/src/handshake.ts +63 -0
- package/worker/src/index.ts +81 -0
- package/worker/src/pairing-code.ts +39 -0
- package/worker/src/pairing-coordinator.ts +145 -0
- package/worker/wrangler-selfhost.toml +36 -0
- package/worker/wrangler.toml +29 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
|
|
7
|
+
// Resolve the hook script relative to THIS source file, not the current
|
|
8
|
+
// working directory. After a global `npm install -g miki-moni` the user
|
|
9
|
+
// will run `miki install-hooks` from anywhere — process.cwd() is no longer
|
|
10
|
+
// the cc-hub repo so `path.resolve("hooks", ...)` would point at the wrong
|
|
11
|
+
// place. Module-relative gives us the package's own hooks/ dir.
|
|
12
|
+
const _moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const HOOK_SCRIPT_ABS = path.resolve(_moduleDir, "..", "hooks", "miki-emit.ps1");
|
|
14
|
+
const MARKER = "miki-emit.ps1";
|
|
15
|
+
// Legacy markers from pre-rename installs. Any hook group whose command
|
|
16
|
+
// references one of these is stripped before we install the current entry —
|
|
17
|
+
// otherwise upgrade-and-rerun produces two hooks firing per event.
|
|
18
|
+
const LEGACY_MARKERS = ["cc-hub-emit.ps1"];
|
|
19
|
+
|
|
20
|
+
const TARGETS: Array<{ key: string; matcher?: string }> = [
|
|
21
|
+
{ key: "SessionStart" },
|
|
22
|
+
{ key: "Stop" },
|
|
23
|
+
{ key: "UserPromptSubmit" },
|
|
24
|
+
{ key: "PreToolUse", matcher: ".*" },
|
|
25
|
+
{ key: "PostToolUse", matcher: ".*" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function commandFor(eventName: string): string {
|
|
29
|
+
return `powershell -NoProfile -File "${HOOK_SCRIPT_ABS}" ${eventName}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readSettings(): Promise<Record<string, any>> {
|
|
33
|
+
let raw: string;
|
|
34
|
+
try {
|
|
35
|
+
raw = await fs.readFile(SETTINGS_PATH, "utf8");
|
|
36
|
+
} catch (err: unknown) {
|
|
37
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(raw);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error(`Could not parse ${SETTINGS_PATH} — file is not valid JSON.`);
|
|
44
|
+
console.error(`Original error: ${(err as Error).message}`);
|
|
45
|
+
console.error(`Fix the file by hand (or restore from a backup), then re-run install:hooks.`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function writeSettings(s: Record<string, any>): Promise<void> {
|
|
51
|
+
await fs.mkdir(path.dirname(SETTINGS_PATH), { recursive: true });
|
|
52
|
+
await fs.writeFile(SETTINGS_PATH, JSON.stringify(s, null, 2));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isLegacyGroup(g: any): boolean {
|
|
56
|
+
if (!Array.isArray(g?.hooks)) return false;
|
|
57
|
+
return g.hooks.some((h: any) =>
|
|
58
|
+
typeof h?.command === "string" && LEGACY_MARKERS.some((m) => h.command.includes(m)),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ensureHookEntry(
|
|
63
|
+
hooks: Record<string, any[]>,
|
|
64
|
+
key: string,
|
|
65
|
+
matcher: string | undefined,
|
|
66
|
+
command: string,
|
|
67
|
+
): void {
|
|
68
|
+
if (!Array.isArray(hooks[key])) hooks[key] = [];
|
|
69
|
+
// Drop legacy entries first so a re-install after rename doesn't double-fire.
|
|
70
|
+
hooks[key] = hooks[key].filter((g) => !isLegacyGroup(g));
|
|
71
|
+
const groups = hooks[key];
|
|
72
|
+
|
|
73
|
+
for (const g of groups) {
|
|
74
|
+
if (Array.isArray(g.hooks)) {
|
|
75
|
+
for (const h of g.hooks) {
|
|
76
|
+
if (typeof h.command === "string" && h.command.includes(MARKER)) return; // already present
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const newGroup: Record<string, any> = { hooks: [{ type: "command", command }] };
|
|
82
|
+
if (matcher) newGroup.matcher = matcher;
|
|
83
|
+
groups.push(newGroup);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function main(): Promise<void> {
|
|
87
|
+
const settings = await readSettings();
|
|
88
|
+
|
|
89
|
+
// Backup once
|
|
90
|
+
const backup = SETTINGS_PATH + ".miki-moni.bak";
|
|
91
|
+
try { await fs.access(backup); }
|
|
92
|
+
catch { try { await fs.copyFile(SETTINGS_PATH, backup); } catch { /* no original yet */ } }
|
|
93
|
+
|
|
94
|
+
if (!settings.hooks || typeof settings.hooks !== "object") settings.hooks = {};
|
|
95
|
+
for (const t of TARGETS) {
|
|
96
|
+
ensureHookEntry(settings.hooks, t.key, t.matcher, commandFor(t.key));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await writeSettings(settings);
|
|
100
|
+
console.log(`Hooks installed to ${SETTINGS_PATH}`);
|
|
101
|
+
console.log(`Backup at ${backup}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
main().catch((err) => {
|
|
105
|
+
console.error(err);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
});
|
package/src/notifier.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import notifier from "node-notifier";
|
|
2
|
+
|
|
3
|
+
export interface NotifyArgs {
|
|
4
|
+
project: string;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type SendFn = (opts: { title: string; message: string }) => void;
|
|
9
|
+
|
|
10
|
+
export const defaultSend: SendFn = (opts) => notifier.notify(opts);
|
|
11
|
+
|
|
12
|
+
export class Notifier {
|
|
13
|
+
constructor(private send: SendFn = defaultSend) {}
|
|
14
|
+
|
|
15
|
+
async notify(args: NotifyArgs): Promise<void> {
|
|
16
|
+
this.send({
|
|
17
|
+
title: `miki-moni · ${args.project}`,
|
|
18
|
+
message: args.message,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/pairing.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import nacl from "tweetnacl";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { deriveSharedSecret, toBase64, fromBase64 } from "./crypto.js";
|
|
4
|
+
import type { PairedPeer } from "./config.js";
|
|
5
|
+
import type { Plaintext } from "./relay-protocol.js";
|
|
6
|
+
|
|
7
|
+
export const PAIRING_TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
const PAIRING_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
|
|
10
|
+
const PAIRING_TOKEN_LENGTH = 16;
|
|
11
|
+
const PAIRING_TOKEN_BYTES = 16;
|
|
12
|
+
|
|
13
|
+
export function generateNewPairingToken(): string {
|
|
14
|
+
const bytes = nacl.randomBytes(PAIRING_TOKEN_LENGTH);
|
|
15
|
+
let out = "";
|
|
16
|
+
for (let i = 0; i < PAIRING_TOKEN_LENGTH; i++) {
|
|
17
|
+
out += PAIRING_ALPHABET[bytes[i]! % PAIRING_ALPHABET.length];
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PairingQrInput {
|
|
23
|
+
worker_url: string;
|
|
24
|
+
pairing_token: string;
|
|
25
|
+
daemon_pubkey: string; // kept for backwards-compat; not used in new URL
|
|
26
|
+
device_name: string; // kept for backwards-compat; not used in new URL
|
|
27
|
+
/** Override the PWA URL — used by self-hosters whose Pages project isn't
|
|
28
|
+
* at miki-moni.pages.dev. Defaults to the hosted convenience URL. */
|
|
29
|
+
phone_pwa_url?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Hosted PWA URL — phone camera opens this directly. Self-hosters override via
|
|
33
|
+
* Config.remote.phone_pwa_url + pass through the input. */
|
|
34
|
+
export const PHONE_PWA_URL = "https://miki-moni.pages.dev/";
|
|
35
|
+
|
|
36
|
+
/** HTTPS URL with token + relay in the URL fragment. Fragment is never sent to
|
|
37
|
+
* the server, so the token doesn't leak into CF/Pages access logs. */
|
|
38
|
+
export function pairingQrPayload(input: PairingQrInput): string {
|
|
39
|
+
const fragment = `t=${input.pairing_token}&r=${encodeURIComponent(input.worker_url)}`;
|
|
40
|
+
const base = input.phone_pwa_url ?? PHONE_PWA_URL;
|
|
41
|
+
return `${base}#${fragment}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function computePeerId(peerPubkeyBase64: string): string {
|
|
45
|
+
return createHash("sha256")
|
|
46
|
+
.update(peerPubkeyBase64)
|
|
47
|
+
.digest("base64")
|
|
48
|
+
.replace(/[+/=]/g, "")
|
|
49
|
+
.slice(0, 16);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type PairingState = "pending" | "paired" | "expired";
|
|
53
|
+
|
|
54
|
+
export interface PairOffer {
|
|
55
|
+
phone_pk: string; // base64
|
|
56
|
+
phone_name: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PairResult {
|
|
60
|
+
peer: PairedPeer;
|
|
61
|
+
pairAck: Extract<Plaintext, { kind: "pair_ack" }>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class PairingSession {
|
|
65
|
+
readonly pairingToken: string;
|
|
66
|
+
state: PairingState = "pending";
|
|
67
|
+
private readonly createdAt: number = Date.now();
|
|
68
|
+
private readonly daemonPrivkey: Uint8Array;
|
|
69
|
+
private readonly daemonPubkey: Uint8Array;
|
|
70
|
+
|
|
71
|
+
constructor(daemonPrivkey: Uint8Array, daemonPubkey: Uint8Array) {
|
|
72
|
+
this.daemonPrivkey = daemonPrivkey;
|
|
73
|
+
this.daemonPubkey = daemonPubkey;
|
|
74
|
+
this.pairingToken = toBase64(nacl.randomBytes(PAIRING_TOKEN_BYTES));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
isExpired(): boolean {
|
|
78
|
+
return Date.now() - this.createdAt >= PAIRING_TOKEN_TTL_MS;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
handleOffer(offer: PairOffer): PairResult {
|
|
82
|
+
if (this.state !== "pending") {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`PairingSession already in state '${this.state}'; cannot accept new offer`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const phonePubkey = fromBase64(offer.phone_pk);
|
|
88
|
+
const sharedSecret = deriveSharedSecret(this.daemonPrivkey, phonePubkey);
|
|
89
|
+
const peer: PairedPeer = {
|
|
90
|
+
peer_id: computePeerId(offer.phone_pk),
|
|
91
|
+
peer_name: offer.phone_name,
|
|
92
|
+
peer_pubkey: offer.phone_pk,
|
|
93
|
+
shared_secret: toBase64(sharedSecret),
|
|
94
|
+
paired_at: Date.now(),
|
|
95
|
+
last_seen_at: null,
|
|
96
|
+
};
|
|
97
|
+
this.state = "paired";
|
|
98
|
+
return { peer, pairAck: { kind: "pair_ack", ok: true } };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Daemon-side mirror of extension/src/protocol.ts. Kept duplicate (not a
|
|
2
|
+
// symlink/shared package) because the extension is a separate npm package
|
|
3
|
+
// with its own dist; cross-package import would complicate the VSIX build.
|
|
4
|
+
// Drift risk is low — types are small and stable. If they ever diverge, both
|
|
5
|
+
// integration test (ws_ext routing) and submitter test will fail.
|
|
6
|
+
|
|
7
|
+
export interface MsgRegister {
|
|
8
|
+
type: "register";
|
|
9
|
+
workspace_root: string;
|
|
10
|
+
helper_version: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MsgSubmitAck {
|
|
14
|
+
type: "submit_ack";
|
|
15
|
+
request_id: string;
|
|
16
|
+
ok: boolean;
|
|
17
|
+
error?: string;
|
|
18
|
+
diag?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MsgPong {
|
|
22
|
+
type: "pong";
|
|
23
|
+
request_id: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ExtMessage = MsgRegister | MsgSubmitAck | MsgPong;
|
|
27
|
+
|
|
28
|
+
export interface MsgSubmit {
|
|
29
|
+
type: "submit";
|
|
30
|
+
request_id: string;
|
|
31
|
+
session_uuid: string;
|
|
32
|
+
prompt: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MsgPing {
|
|
36
|
+
type: "ping";
|
|
37
|
+
request_id: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type DaemonMessage = MsgSubmit | MsgPing;
|
|
41
|
+
|
|
42
|
+
export function normalizePath(p: string): string {
|
|
43
|
+
let n = p.replace(/\\/g, "/").toLowerCase();
|
|
44
|
+
if (n.length > 1 && n.endsWith("/")) n = n.slice(0, -1);
|
|
45
|
+
return n;
|
|
46
|
+
}
|