@mkterswingman/5mghost-twinkler 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +52 -18
- package/dist/jobLedger.d.ts +31 -0
- package/dist/jobLedger.js +154 -0
- package/dist/oauthLogin.d.ts +11 -0
- package/dist/oauthLogin.js +247 -0
- package/dist/paths.d.ts +2 -0
- package/dist/paths.js +2 -0
- package/dist/twinklerClient.js +1 -1
- package/package.json +1 -1
- package/skills/use-5mghost-twinkler/SKILL.md +25 -16
- package/skills/setup-5mghost-twinkler/SKILL.md +0 -71
- package/skills/use-5mghost-twinkler/scripts/create-watch-monitor.mjs +0 -57
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@ Lightweight AI runtime for the hosted Twinkler API.
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install -g @mkterswingman/5mghost-twinkler
|
|
7
7
|
twinkler ensure --auto-update --json
|
|
8
|
+
twinkler login
|
|
8
9
|
twinkler call GET /api/v1/channel/ibai/summary --query days=30
|
|
9
10
|
```
|
|
10
11
|
|
|
@@ -13,6 +14,12 @@ clients. `twinkler setup` remains an advanced repair command when a skill target
|
|
|
13
14
|
was unavailable during install; normal AI workflows should start with
|
|
14
15
|
`twinkler ensure --auto-update --json`.
|
|
15
16
|
|
|
16
|
-
Auth
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
Auth defaults to browser OAuth via `twinkler login`. PAT login is an advanced
|
|
18
|
+
fallback with `twinkler login --pat-stdin`.
|
|
19
|
+
|
|
20
|
+
Watch jobs are remembered locally in `~/.mkterswingman/twinkler/jobs.json` so AI
|
|
21
|
+
sessions can recover active and recent jobs without making users remember job
|
|
22
|
+
IDs.
|
|
23
|
+
|
|
24
|
+
Do not commit `.env`, `auth.json`, PATs, bearer tokens, or any generated secret
|
|
25
|
+
file to a remote repository.
|
package/dist/cli.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface CliContext {
|
|
|
8
8
|
stderr?: (message: string) => void;
|
|
9
9
|
stdin?: AsyncIterable<Buffer | string>;
|
|
10
10
|
fetchImpl?: typeof fetch;
|
|
11
|
+
openUrlImpl?: (url: string) => void | Promise<void>;
|
|
11
12
|
spawnSyncImpl?: typeof spawnSync;
|
|
12
13
|
}
|
|
13
14
|
export declare function runCli(context?: CliContext): Promise<number>;
|
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,8 @@ import { spawnSync } from "node:child_process";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { getAuthStatus, getValidToken, logout, PAT_LOGIN_URL, savePat } from "./auth.js";
|
|
7
|
+
import { readJobLedger, reconcileWatchLedger } from "./jobLedger.js";
|
|
8
|
+
import { runOAuthLogin } from "./oauthLogin.js";
|
|
7
9
|
import { resolveTwinklerPaths } from "./paths.js";
|
|
8
10
|
import { installBundledSkills } from "./skillInstall.js";
|
|
9
11
|
import { callTwinkler } from "./twinklerClient.js";
|
|
@@ -15,18 +17,20 @@ const HELP = [
|
|
|
15
17
|
" twinkler login",
|
|
16
18
|
" twinkler ensure [--auto-update] [--json]",
|
|
17
19
|
" twinkler auth status",
|
|
18
|
-
" twinkler auth login
|
|
20
|
+
" twinkler auth login",
|
|
19
21
|
" twinkler call <GET|POST|DELETE> /api/v1/... [--query k=v] [--json '{...}']",
|
|
20
|
-
" twinkler jobs active|recent|show <job_id
|
|
22
|
+
" twinkler jobs active|recent|show <job_id>|ledger",
|
|
21
23
|
" twinkler update",
|
|
22
24
|
" twinkler version",
|
|
23
25
|
"",
|
|
24
26
|
"Repair / advanced:",
|
|
25
27
|
" twinkler setup",
|
|
26
28
|
" twinkler install-skills",
|
|
29
|
+
" twinkler login --pat-stdin",
|
|
30
|
+
" twinkler auth login --pat-stdin",
|
|
27
31
|
" twinkler auth login --pat <TOKEN>",
|
|
28
32
|
"",
|
|
29
|
-
"
|
|
33
|
+
"PAT fallback:",
|
|
30
34
|
` ${PAT_LOGIN_URL}`
|
|
31
35
|
].join("\n");
|
|
32
36
|
const PACKAGE_NAME = "@mkterswingman/5mghost-twinkler";
|
|
@@ -56,15 +60,17 @@ export async function runCli(context = {}) {
|
|
|
56
60
|
env,
|
|
57
61
|
out,
|
|
58
62
|
stdin: context.stdin ?? process.stdin,
|
|
59
|
-
fetchImpl: context.fetchImpl
|
|
63
|
+
fetchImpl: context.fetchImpl,
|
|
64
|
+
openUrlImpl: context.openUrlImpl
|
|
60
65
|
});
|
|
61
66
|
case "login":
|
|
62
|
-
return await runAuthCommand(["login",
|
|
67
|
+
return await runAuthCommand(["login", ...args], {
|
|
63
68
|
paths,
|
|
64
69
|
env,
|
|
65
70
|
out,
|
|
66
71
|
stdin: context.stdin ?? process.stdin,
|
|
67
|
-
fetchImpl: context.fetchImpl
|
|
72
|
+
fetchImpl: context.fetchImpl,
|
|
73
|
+
openUrlImpl: context.openUrlImpl
|
|
68
74
|
});
|
|
69
75
|
case "ensure":
|
|
70
76
|
return await runEnsureCommand(args, {
|
|
@@ -83,6 +89,7 @@ export async function runCli(context = {}) {
|
|
|
83
89
|
env,
|
|
84
90
|
fetchImpl: context.fetchImpl
|
|
85
91
|
});
|
|
92
|
+
reconcileCallResult(paths.jobsLedgerPath, result, parsed);
|
|
86
93
|
out(JSON.stringify(result, null, 2));
|
|
87
94
|
return 0;
|
|
88
95
|
}
|
|
@@ -106,7 +113,7 @@ export async function runCli(context = {}) {
|
|
|
106
113
|
renderSkillInstallResults(results),
|
|
107
114
|
auth.authenticated
|
|
108
115
|
? `Auth: ready (${auth.type} via ${auth.source})`
|
|
109
|
-
:
|
|
116
|
+
: "Auth: missing. Run 'twinkler login' and complete the browser login."
|
|
110
117
|
].join("\n"));
|
|
111
118
|
return results.some((result) => result.status === "error") ? 1 : 0;
|
|
112
119
|
}
|
|
@@ -142,10 +149,11 @@ async function runAuthCommand(args, context) {
|
|
|
142
149
|
context.out([
|
|
143
150
|
"Usage:",
|
|
144
151
|
" twinkler auth status",
|
|
145
|
-
" twinkler auth login
|
|
152
|
+
" twinkler auth login",
|
|
146
153
|
" twinkler auth logout",
|
|
147
154
|
"",
|
|
148
|
-
"Advanced compatibility:",
|
|
155
|
+
"Advanced PAT compatibility:",
|
|
156
|
+
" twinkler auth login --pat-stdin",
|
|
149
157
|
" twinkler auth login --pat <TOKEN>",
|
|
150
158
|
"",
|
|
151
159
|
`PAT page: ${PAT_LOGIN_URL}`
|
|
@@ -157,7 +165,7 @@ async function runAuthCommand(args, context) {
|
|
|
157
165
|
if (!status.authenticated) {
|
|
158
166
|
context.out([
|
|
159
167
|
"Not logged in.",
|
|
160
|
-
|
|
168
|
+
"Run 'twinkler login' and complete the browser login. Advanced fallback: 'twinkler login --pat-stdin'."
|
|
161
169
|
].join("\n"));
|
|
162
170
|
return 1;
|
|
163
171
|
}
|
|
@@ -177,14 +185,16 @@ async function runAuthCommand(args, context) {
|
|
|
177
185
|
if (subcommand === "login") {
|
|
178
186
|
const patFlagIndex = args.indexOf("--pat");
|
|
179
187
|
const patStdin = args.includes("--pat-stdin");
|
|
180
|
-
const token = patStdin ? await readAllStdin(context.stdin) : args[patFlagIndex + 1];
|
|
181
188
|
if (patFlagIndex === -1 && !patStdin) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
189
|
+
await runOAuthLogin({
|
|
190
|
+
authJsonPath: context.paths.authJsonPath,
|
|
191
|
+
fetchImpl: context.fetchImpl,
|
|
192
|
+
openUrlImpl: context.openUrlImpl,
|
|
193
|
+
out: context.out
|
|
194
|
+
});
|
|
195
|
+
return 0;
|
|
187
196
|
}
|
|
197
|
+
const token = patStdin ? await readAllStdin(context.stdin) : args[patFlagIndex + 1];
|
|
188
198
|
savePat(context.paths.authJsonPath, token ?? "");
|
|
189
199
|
context.out("mkterswingman PAT saved.");
|
|
190
200
|
return 0;
|
|
@@ -304,6 +314,10 @@ function parseSemver(version) {
|
|
|
304
314
|
async function runJobsCommand(args, context) {
|
|
305
315
|
const [subcommand, value] = args.filter((arg) => arg !== "--json");
|
|
306
316
|
let path;
|
|
317
|
+
if (subcommand === "ledger" || subcommand === "local") {
|
|
318
|
+
context.out(JSON.stringify(readJobLedger(context.paths.jobsLedgerPath), null, 2));
|
|
319
|
+
return 0;
|
|
320
|
+
}
|
|
307
321
|
if (subcommand === "active") {
|
|
308
322
|
path = "/api/v1/twitch/watch";
|
|
309
323
|
}
|
|
@@ -318,7 +332,8 @@ async function runJobsCommand(args, context) {
|
|
|
318
332
|
"Usage:",
|
|
319
333
|
" twinkler jobs active",
|
|
320
334
|
" twinkler jobs recent",
|
|
321
|
-
" twinkler jobs show <job_id>"
|
|
335
|
+
" twinkler jobs show <job_id>",
|
|
336
|
+
" twinkler jobs ledger"
|
|
322
337
|
].join("\n"));
|
|
323
338
|
return 1;
|
|
324
339
|
}
|
|
@@ -331,6 +346,7 @@ async function runJobsCommand(args, context) {
|
|
|
331
346
|
env: context.env,
|
|
332
347
|
fetchImpl: context.fetchImpl
|
|
333
348
|
});
|
|
349
|
+
reconcileWatchLedger(context.paths.jobsLedgerPath, result, { source: `jobs ${subcommand}` });
|
|
334
350
|
context.out(JSON.stringify(result, null, 2));
|
|
335
351
|
return 0;
|
|
336
352
|
}
|
|
@@ -341,6 +357,7 @@ function parseCallArgs(args) {
|
|
|
341
357
|
}
|
|
342
358
|
const query = {};
|
|
343
359
|
let jsonBody;
|
|
360
|
+
let userIntent = null;
|
|
344
361
|
for (let index = 0; index < rest.length; index += 1) {
|
|
345
362
|
const arg = rest[index];
|
|
346
363
|
if (arg === "--query") {
|
|
@@ -360,9 +377,26 @@ function parseCallArgs(args) {
|
|
|
360
377
|
jsonBody = JSON.parse(raw.startsWith("@") ? readFileSync(raw.slice(1), "utf8") : raw);
|
|
361
378
|
continue;
|
|
362
379
|
}
|
|
380
|
+
if (arg === "--intent") {
|
|
381
|
+
const raw = rest[++index];
|
|
382
|
+
if (!raw)
|
|
383
|
+
throw new Error("--intent expects a short description");
|
|
384
|
+
userIntent = raw;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
363
387
|
throw new Error(`Unknown call option: ${arg}`);
|
|
364
388
|
}
|
|
365
|
-
return { method, path, query, jsonBody };
|
|
389
|
+
return { method, path, query, jsonBody, userIntent };
|
|
390
|
+
}
|
|
391
|
+
function reconcileCallResult(ledgerPath, result, parsed) {
|
|
392
|
+
const normalizedPath = parsed.path.startsWith("/") ? parsed.path : `/${parsed.path}`;
|
|
393
|
+
if (!normalizedPath.startsWith("/api/v1/twitch/watch"))
|
|
394
|
+
return;
|
|
395
|
+
reconcileWatchLedger(ledgerPath, result, {
|
|
396
|
+
source: `call ${parsed.method.toUpperCase()} ${normalizedPath}`,
|
|
397
|
+
requestBody: parsed.jsonBody,
|
|
398
|
+
userIntent: parsed.userIntent
|
|
399
|
+
});
|
|
366
400
|
}
|
|
367
401
|
function renderSkillInstallResults(results) {
|
|
368
402
|
return [
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface WatchJobLedger {
|
|
2
|
+
version: 1;
|
|
3
|
+
jobs: WatchJobLedgerRecord[];
|
|
4
|
+
}
|
|
5
|
+
export interface WatchJobLedgerRecord {
|
|
6
|
+
job_id: string;
|
|
7
|
+
created_at: string;
|
|
8
|
+
updated_at: string;
|
|
9
|
+
user_intent: string | null;
|
|
10
|
+
logins: string[];
|
|
11
|
+
collect: string[];
|
|
12
|
+
start: unknown;
|
|
13
|
+
stop: unknown;
|
|
14
|
+
status: string;
|
|
15
|
+
last_seen_at: string;
|
|
16
|
+
last_counts: {
|
|
17
|
+
ccv_points: number | null;
|
|
18
|
+
chat_messages: number | null;
|
|
19
|
+
};
|
|
20
|
+
latest_ccv: unknown;
|
|
21
|
+
source: string;
|
|
22
|
+
server: unknown;
|
|
23
|
+
}
|
|
24
|
+
export declare function readJobLedger(path: string): WatchJobLedger;
|
|
25
|
+
export declare function writeJobLedger(path: string, ledger: WatchJobLedger): void;
|
|
26
|
+
export declare function reconcileWatchLedger(ledgerPath: string, response: unknown, options?: {
|
|
27
|
+
source: string;
|
|
28
|
+
requestBody?: unknown;
|
|
29
|
+
userIntent?: string | null;
|
|
30
|
+
now?: () => Date;
|
|
31
|
+
}): void;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
export function readJobLedger(path) {
|
|
4
|
+
if (!existsSync(path))
|
|
5
|
+
return emptyLedger();
|
|
6
|
+
try {
|
|
7
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
8
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.jobs))
|
|
9
|
+
return emptyLedger();
|
|
10
|
+
return {
|
|
11
|
+
version: 1,
|
|
12
|
+
jobs: parsed.jobs.flatMap((job) => normalizeLedgerRecord(job))
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return emptyLedger();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function writeJobLedger(path, ledger) {
|
|
20
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
21
|
+
try {
|
|
22
|
+
chmodSync(dirname(path), 0o700);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Windows may ignore POSIX modes.
|
|
26
|
+
}
|
|
27
|
+
const tmpPath = `${path}.tmp`;
|
|
28
|
+
writeFileSync(tmpPath, JSON.stringify(ledger, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
29
|
+
renameSync(tmpPath, path);
|
|
30
|
+
try {
|
|
31
|
+
chmodSync(path, 0o600);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Windows may ignore POSIX modes.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function reconcileWatchLedger(ledgerPath, response, options = { source: "unknown" }) {
|
|
38
|
+
const records = extractWatchRecords(response, options);
|
|
39
|
+
if (records.length === 0)
|
|
40
|
+
return;
|
|
41
|
+
const ledger = readJobLedger(ledgerPath);
|
|
42
|
+
const byId = new Map(ledger.jobs.map((job) => [job.job_id, job]));
|
|
43
|
+
for (const record of records) {
|
|
44
|
+
const existing = byId.get(record.job_id);
|
|
45
|
+
byId.set(record.job_id, mergeRecord(existing, record));
|
|
46
|
+
}
|
|
47
|
+
writeJobLedger(ledgerPath, {
|
|
48
|
+
version: 1,
|
|
49
|
+
jobs: [...byId.values()].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function extractWatchRecords(response, options) {
|
|
53
|
+
const envelope = asRecord(response);
|
|
54
|
+
const data = asRecord(envelope?.data);
|
|
55
|
+
if (!data)
|
|
56
|
+
return [];
|
|
57
|
+
const nowIso = (options.now ?? (() => new Date()))().toISOString();
|
|
58
|
+
if (Array.isArray(data.jobs)) {
|
|
59
|
+
return data.jobs.flatMap((job) => buildRecord(asRecord(job), options, nowIso));
|
|
60
|
+
}
|
|
61
|
+
return buildRecord(data, options, nowIso);
|
|
62
|
+
}
|
|
63
|
+
function buildRecord(data, options, nowIso) {
|
|
64
|
+
if (!data || typeof data.job_id !== "string")
|
|
65
|
+
return [];
|
|
66
|
+
const requestBody = asRecord(options.requestBody);
|
|
67
|
+
const accumulated = asRecord(data.accumulated);
|
|
68
|
+
return [{
|
|
69
|
+
job_id: data.job_id,
|
|
70
|
+
created_at: typeof data.created_at === "string" ? data.created_at : nowIso,
|
|
71
|
+
updated_at: nowIso,
|
|
72
|
+
user_intent: options.userIntent ?? null,
|
|
73
|
+
logins: stringArray(data.logins ?? requestBody?.logins),
|
|
74
|
+
collect: stringArray(data.collect ?? requestBody?.collect),
|
|
75
|
+
start: data.start ?? requestBody?.start ?? null,
|
|
76
|
+
stop: data.stop ?? requestBody?.stop ?? null,
|
|
77
|
+
status: typeof data.status === "string" ? data.status : "unknown",
|
|
78
|
+
last_seen_at: nowIso,
|
|
79
|
+
last_counts: {
|
|
80
|
+
ccv_points: numberOrNull(accumulated?.ccv_points),
|
|
81
|
+
chat_messages: numberOrNull(accumulated?.chat_messages)
|
|
82
|
+
},
|
|
83
|
+
latest_ccv: data.latest_ccv ?? null,
|
|
84
|
+
source: options.source,
|
|
85
|
+
server: data
|
|
86
|
+
}];
|
|
87
|
+
}
|
|
88
|
+
function mergeRecord(existing, next) {
|
|
89
|
+
if (!existing)
|
|
90
|
+
return next;
|
|
91
|
+
return {
|
|
92
|
+
...existing,
|
|
93
|
+
...next,
|
|
94
|
+
user_intent: next.user_intent ?? existing.user_intent,
|
|
95
|
+
logins: next.logins.length > 0 ? next.logins : existing.logins,
|
|
96
|
+
collect: next.collect.length > 0 ? next.collect : existing.collect,
|
|
97
|
+
start: next.start ?? existing.start,
|
|
98
|
+
stop: next.stop ?? existing.stop,
|
|
99
|
+
last_counts: {
|
|
100
|
+
ccv_points: next.last_counts.ccv_points ?? existing.last_counts.ccv_points,
|
|
101
|
+
chat_messages: next.last_counts.chat_messages ?? existing.last_counts.chat_messages
|
|
102
|
+
},
|
|
103
|
+
latest_ccv: next.latest_ccv ?? existing.latest_ccv
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function emptyLedger() {
|
|
107
|
+
return { version: 1, jobs: [] };
|
|
108
|
+
}
|
|
109
|
+
function asRecord(value) {
|
|
110
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
111
|
+
? value
|
|
112
|
+
: null;
|
|
113
|
+
}
|
|
114
|
+
function stringArray(value) {
|
|
115
|
+
return Array.isArray(value)
|
|
116
|
+
? value.filter((item) => typeof item === "string")
|
|
117
|
+
: [];
|
|
118
|
+
}
|
|
119
|
+
function numberOrNull(value) {
|
|
120
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
121
|
+
}
|
|
122
|
+
function normalizeLedgerRecord(value) {
|
|
123
|
+
const record = asRecord(value);
|
|
124
|
+
if (!record ||
|
|
125
|
+
typeof record.job_id !== "string" ||
|
|
126
|
+
typeof record.created_at !== "string" ||
|
|
127
|
+
typeof record.updated_at !== "string" ||
|
|
128
|
+
typeof record.status !== "string" ||
|
|
129
|
+
typeof record.last_seen_at !== "string" ||
|
|
130
|
+
!Array.isArray(record.logins) ||
|
|
131
|
+
!Array.isArray(record.collect)) {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
const counts = asRecord(record.last_counts);
|
|
135
|
+
return [{
|
|
136
|
+
job_id: record.job_id,
|
|
137
|
+
created_at: record.created_at,
|
|
138
|
+
updated_at: record.updated_at,
|
|
139
|
+
user_intent: typeof record.user_intent === "string" ? record.user_intent : null,
|
|
140
|
+
logins: stringArray(record.logins),
|
|
141
|
+
collect: stringArray(record.collect),
|
|
142
|
+
start: record.start ?? null,
|
|
143
|
+
stop: record.stop ?? null,
|
|
144
|
+
status: record.status,
|
|
145
|
+
last_seen_at: record.last_seen_at,
|
|
146
|
+
last_counts: {
|
|
147
|
+
ccv_points: numberOrNull(counts?.ccv_points),
|
|
148
|
+
chat_messages: numberOrNull(counts?.chat_messages)
|
|
149
|
+
},
|
|
150
|
+
latest_ccv: record.latest_ccv ?? null,
|
|
151
|
+
source: typeof record.source === "string" ? record.source : "unknown",
|
|
152
|
+
server: record.server ?? null
|
|
153
|
+
}];
|
|
154
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface OAuthLoginOptions {
|
|
2
|
+
authJsonPath: string;
|
|
3
|
+
authUrl?: string;
|
|
4
|
+
clientName?: string;
|
|
5
|
+
fetchImpl?: typeof fetch;
|
|
6
|
+
openUrlImpl?: (url: string) => void | Promise<void>;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
out?: (message: string) => void;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
}
|
|
11
|
+
export declare function runOAuthLogin(options: OAuthLoginOptions): Promise<void>;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { writeSharedAuth } from "./auth.js";
|
|
5
|
+
const DEFAULT_AUTH_URL = "https://mkterswingman.com";
|
|
6
|
+
const DEFAULT_SCOPE = "openid profile email mcp:use";
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
8
|
+
export async function runOAuthLogin(options) {
|
|
9
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
10
|
+
const authUrl = (options.authUrl ?? DEFAULT_AUTH_URL).replace(/\/+$/, "");
|
|
11
|
+
const out = options.out ?? (() => undefined);
|
|
12
|
+
const now = options.now ?? Date.now;
|
|
13
|
+
const codeVerifier = token(32);
|
|
14
|
+
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
15
|
+
const state = token(32);
|
|
16
|
+
let server = null;
|
|
17
|
+
try {
|
|
18
|
+
server = createServer();
|
|
19
|
+
const redirectUri = await listenOnLoopback(server);
|
|
20
|
+
const registered = await registerClient(fetchImpl, authUrl, {
|
|
21
|
+
clientName: options.clientName ?? "5mghost-twinkler-cli",
|
|
22
|
+
redirectUri
|
|
23
|
+
});
|
|
24
|
+
const callback = waitForCallback({
|
|
25
|
+
server,
|
|
26
|
+
state,
|
|
27
|
+
authUrl,
|
|
28
|
+
redirectUri,
|
|
29
|
+
clientId: registered.client_id,
|
|
30
|
+
clientSecret: registered.client_secret,
|
|
31
|
+
codeVerifier,
|
|
32
|
+
fetchImpl
|
|
33
|
+
});
|
|
34
|
+
const authorizeUrl = buildAuthorizeUrl(authUrl, {
|
|
35
|
+
clientId: registered.client_id,
|
|
36
|
+
redirectUri,
|
|
37
|
+
codeChallenge,
|
|
38
|
+
state
|
|
39
|
+
});
|
|
40
|
+
out("Opening browser for mkterswingman login.");
|
|
41
|
+
out(authorizeUrl);
|
|
42
|
+
try {
|
|
43
|
+
await (options.openUrlImpl ?? openUrl)(authorizeUrl);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
out("Could not open the browser automatically. Open the URL above to continue login.");
|
|
47
|
+
}
|
|
48
|
+
const tokens = await withTimeout(callback, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
49
|
+
writeSharedAuth(options.authJsonPath, {
|
|
50
|
+
type: "jwt",
|
|
51
|
+
access_token: tokens.access_token,
|
|
52
|
+
refresh_token: tokens.refresh_token,
|
|
53
|
+
expires_at: now() + tokens.expires_in * 1000,
|
|
54
|
+
client_id: registered.client_id
|
|
55
|
+
});
|
|
56
|
+
out("mkterswingman OAuth login saved.");
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
if (server) {
|
|
60
|
+
await closeServer(server);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function registerClient(fetchImpl, authUrl, input) {
|
|
65
|
+
const response = await fetchImpl(`${authUrl}/oauth/register`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
client_name: input.clientName,
|
|
70
|
+
redirect_uris: [input.redirectUri],
|
|
71
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
72
|
+
response_types: ["code"],
|
|
73
|
+
token_endpoint_auth_method: "none",
|
|
74
|
+
scope: DEFAULT_SCOPE
|
|
75
|
+
})
|
|
76
|
+
});
|
|
77
|
+
const body = await readJson(response);
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new Error(`OAuth client registration failed: HTTP ${response.status} ${summarize(body)}`);
|
|
80
|
+
}
|
|
81
|
+
const clientId = stringField(body, "client_id");
|
|
82
|
+
return {
|
|
83
|
+
client_id: clientId,
|
|
84
|
+
client_secret: typeof body.client_secret === "string" ? body.client_secret : undefined
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function waitForCallback(input) {
|
|
88
|
+
let settled = false;
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
input.server.on("request", async (req, res) => {
|
|
91
|
+
if (settled) {
|
|
92
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
93
|
+
res.end("<h1>Login already completed</h1>");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const url = new URL(req.url ?? "/", input.redirectUri);
|
|
97
|
+
if (url.pathname !== new URL(input.redirectUri).pathname) {
|
|
98
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
99
|
+
res.end("<h1>Waiting for authorization...</h1>");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const error = url.searchParams.get("error");
|
|
103
|
+
if (error) {
|
|
104
|
+
settled = true;
|
|
105
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
106
|
+
res.end(`<h1>Authorization failed</h1><p>${escapeHtml(error)}</p>`);
|
|
107
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const code = url.searchParams.get("code");
|
|
111
|
+
if (!code) {
|
|
112
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
113
|
+
res.end("<h1>Waiting for authorization...</h1>");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (url.searchParams.get("state") !== input.state) {
|
|
117
|
+
settled = true;
|
|
118
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
119
|
+
res.end("<h1>Authorization failed</h1><p>State mismatch.</p>");
|
|
120
|
+
reject(new Error("OAuth state mismatch"));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const tokens = await exchangeCode(input, code);
|
|
125
|
+
settled = true;
|
|
126
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
127
|
+
res.end("<h1>Login complete</h1><p>You can close this window.</p>");
|
|
128
|
+
resolve(tokens);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
settled = true;
|
|
132
|
+
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
|
|
133
|
+
res.end("<h1>Token exchange failed</h1>");
|
|
134
|
+
reject(error);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async function exchangeCode(input, code) {
|
|
140
|
+
const response = await input.fetchImpl(`${input.authUrl}/oauth/token`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
grant_type: "authorization_code",
|
|
145
|
+
code,
|
|
146
|
+
redirect_uri: input.redirectUri,
|
|
147
|
+
client_id: input.clientId,
|
|
148
|
+
code_verifier: input.codeVerifier,
|
|
149
|
+
...(input.clientSecret ? { client_secret: input.clientSecret } : {})
|
|
150
|
+
})
|
|
151
|
+
});
|
|
152
|
+
const body = await readJson(response);
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
throw new Error(`OAuth token exchange failed: HTTP ${response.status} ${summarize(body)}`);
|
|
155
|
+
}
|
|
156
|
+
const accessToken = stringField(body, "access_token");
|
|
157
|
+
const refreshToken = stringField(body, "refresh_token");
|
|
158
|
+
const expiresIn = typeof body.expires_in === "number" ? body.expires_in : Number(body.expires_in);
|
|
159
|
+
if (!Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
160
|
+
throw new Error("OAuth token exchange response missing expires_in");
|
|
161
|
+
}
|
|
162
|
+
return { access_token: accessToken, refresh_token: refreshToken, expires_in: expiresIn };
|
|
163
|
+
}
|
|
164
|
+
function buildAuthorizeUrl(authUrl, input) {
|
|
165
|
+
const url = new URL(`${authUrl}/oauth/authorize`);
|
|
166
|
+
url.searchParams.set("response_type", "code");
|
|
167
|
+
url.searchParams.set("client_id", input.clientId);
|
|
168
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
169
|
+
url.searchParams.set("code_challenge", input.codeChallenge);
|
|
170
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
171
|
+
url.searchParams.set("state", input.state);
|
|
172
|
+
url.searchParams.set("scope", DEFAULT_SCOPE);
|
|
173
|
+
return url.toString();
|
|
174
|
+
}
|
|
175
|
+
function listenOnLoopback(server) {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
server.once("error", reject);
|
|
178
|
+
server.listen(0, "127.0.0.1", () => {
|
|
179
|
+
const address = server.address();
|
|
180
|
+
if (!address || typeof address === "string") {
|
|
181
|
+
reject(new Error("Failed to bind OAuth callback server"));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
resolve(`http://127.0.0.1:${address.port}/callback`);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function withTimeout(promise, timeoutMs) {
|
|
189
|
+
let timeout;
|
|
190
|
+
const timer = new Promise((_resolve, reject) => {
|
|
191
|
+
timeout = setTimeout(() => reject(new Error(`OAuth login timed out after ${Math.ceil(timeoutMs / 1000)} seconds`)), timeoutMs);
|
|
192
|
+
});
|
|
193
|
+
return Promise.race([promise, timer]).finally(() => {
|
|
194
|
+
if (timeout)
|
|
195
|
+
clearTimeout(timeout);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
async function readJson(response) {
|
|
199
|
+
const text = await response.text();
|
|
200
|
+
if (!text)
|
|
201
|
+
return {};
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(text);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return { raw: text };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function stringField(body, field) {
|
|
210
|
+
const value = body[field];
|
|
211
|
+
if (typeof value !== "string" || !value) {
|
|
212
|
+
throw new Error(`OAuth response missing ${field}`);
|
|
213
|
+
}
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
function summarize(body) {
|
|
217
|
+
if (typeof body.error_description === "string")
|
|
218
|
+
return body.error_description;
|
|
219
|
+
if (typeof body.error === "string")
|
|
220
|
+
return body.error;
|
|
221
|
+
if (typeof body.raw === "string")
|
|
222
|
+
return body.raw.slice(0, 200);
|
|
223
|
+
return JSON.stringify(body).slice(0, 200);
|
|
224
|
+
}
|
|
225
|
+
function openUrl(url) {
|
|
226
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
227
|
+
const args = process.platform === "darwin" ? [url] : process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
228
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
229
|
+
child.unref();
|
|
230
|
+
}
|
|
231
|
+
function token(bytes) {
|
|
232
|
+
return randomBytes(bytes).toString("base64url");
|
|
233
|
+
}
|
|
234
|
+
function closeServer(server) {
|
|
235
|
+
return new Promise((resolve) => {
|
|
236
|
+
server.close(() => resolve());
|
|
237
|
+
server.once("error", () => resolve());
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function escapeHtml(value) {
|
|
241
|
+
return value
|
|
242
|
+
.replaceAll("&", "&")
|
|
243
|
+
.replaceAll("<", "<")
|
|
244
|
+
.replaceAll(">", ">")
|
|
245
|
+
.replaceAll('"', """)
|
|
246
|
+
.replaceAll("'", "'");
|
|
247
|
+
}
|
package/dist/paths.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface TwinklerPaths {
|
|
|
5
5
|
homeDir: string;
|
|
6
6
|
mkterswingmanDir: string;
|
|
7
7
|
authJsonPath: string;
|
|
8
|
+
twinklerDir: string;
|
|
9
|
+
jobsLedgerPath: string;
|
|
8
10
|
skillsManifestPath: string;
|
|
9
11
|
}
|
|
10
12
|
export declare function resolveTwinklerPaths(options?: PathOptions): TwinklerPaths;
|
package/dist/paths.js
CHANGED
|
@@ -8,6 +8,8 @@ export function resolveTwinklerPaths(options = {}) {
|
|
|
8
8
|
homeDir,
|
|
9
9
|
mkterswingmanDir: join(homeDir, ".mkterswingman"),
|
|
10
10
|
authJsonPath: join(homeDir, ".mkterswingman", "auth.json"),
|
|
11
|
+
twinklerDir: join(homeDir, ".mkterswingman", "twinkler"),
|
|
12
|
+
jobsLedgerPath: join(homeDir, ".mkterswingman", "twinkler", "jobs.json"),
|
|
11
13
|
skillsManifestPath: resolveBundledAssetPath("skills.manifest.json")
|
|
12
14
|
};
|
|
13
15
|
}
|
package/dist/twinklerClient.js
CHANGED
|
@@ -9,7 +9,7 @@ export async function callTwinkler(options) {
|
|
|
9
9
|
fetchImpl: options.fetchImpl
|
|
10
10
|
});
|
|
11
11
|
if (!token) {
|
|
12
|
-
throw new Error(`Missing mkterswingman auth.
|
|
12
|
+
throw new Error(`Missing mkterswingman auth. Run 'twinkler login' and complete the browser login. Advanced fallback: run 'twinkler login --pat-stdin' or set ${MKTERSWINGMAN_PAT_ENV}.`);
|
|
13
13
|
}
|
|
14
14
|
const url = buildUrl(path, options.query ?? {});
|
|
15
15
|
const response = await (options.fetchImpl ?? fetch)(url, {
|
package/package.json
CHANGED
|
@@ -29,9 +29,9 @@ Read the JSON.
|
|
|
29
29
|
- If `update.status` is `updated`, continue the current task. Tell the user only
|
|
30
30
|
that the helper is updated and a new AI session is needed to load changed skill
|
|
31
31
|
text.
|
|
32
|
-
- If auth is missing or expired, run `twinkler login
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
- If auth is missing or expired, run `twinkler login`. It opens the browser
|
|
33
|
+
OAuth login and stores a refreshable local token. Use PAT only as an advanced
|
|
34
|
+
fallback: run `twinkler login --pat-stdin` if browser login is unavailable.
|
|
35
35
|
- If update failed but the current helper can still complete the task, continue
|
|
36
36
|
and mention the update failure briefly. Stop only when the helper is too old
|
|
37
37
|
for the requested command.
|
|
@@ -70,16 +70,26 @@ collect chat messages, or stop when the stream ends or changes game.
|
|
|
70
70
|
Before creating a new watch job in a new session, recover existing work:
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
|
+
twinkler jobs ledger
|
|
73
74
|
twinkler jobs active
|
|
74
75
|
twinkler jobs recent
|
|
75
76
|
```
|
|
76
77
|
|
|
78
|
+
The local job ledger lives at `~/.mkterswingman/twinkler/jobs.json`. It is the
|
|
79
|
+
cross-session memory for watch jobs. Always inspect it before creating another
|
|
80
|
+
job when the user asks about monitoring, pending results, "上次那个 job", or any
|
|
81
|
+
vague live-watch status.
|
|
82
|
+
|
|
77
83
|
Create a job:
|
|
78
84
|
|
|
79
85
|
```bash
|
|
80
|
-
twinkler call POST /api/v1/twitch/watch --json '{"logins":["theburntpeanut"],"collect":["ccv","chat"],"start":{"type":"time","at":"now"},"stop":{"type":"game","game_name":"ARC Raiders"}}'
|
|
86
|
+
twinkler call POST /api/v1/twitch/watch --intent "盯 theburntpeanut until stream ends or no longer ARC Raiders" --json '{"logins":["theburntpeanut"],"collect":["ccv","chat"],"start":{"type":"time","at":"now"},"stop":{"type":"game","game_name":"ARC Raiders"}}'
|
|
81
87
|
```
|
|
82
88
|
|
|
89
|
+
Use `--intent` for the user's plain-language goal. The helper does not send it
|
|
90
|
+
to Twinkler; it stores it only in the local ledger so a later AI session can
|
|
91
|
+
understand which job is which and when it should stop.
|
|
92
|
+
|
|
83
93
|
Then inspect it with:
|
|
84
94
|
|
|
85
95
|
```bash
|
|
@@ -88,20 +98,19 @@ twinkler call GET /api/v1/twitch/watch/<job_id>/ccv
|
|
|
88
98
|
twinkler call GET /api/v1/twitch/watch/<job_id>/chat
|
|
89
99
|
```
|
|
90
100
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
node scripts/create-watch-monitor.mjs --job-id <job_id> --cadence-min 10 --stop "stream ended or no longer matches requested game"
|
|
96
|
-
```
|
|
101
|
+
Do not make the user remember job IDs or recovery commands. Use the ledger and
|
|
102
|
+
hosted jobs endpoints yourself in new sessions. If the AI runtime has a
|
|
103
|
+
heartbeat/reminder tool, you may use it, but it is optional; the ledger is the
|
|
104
|
+
required recovery mechanism.
|
|
97
105
|
|
|
98
|
-
|
|
99
|
-
submit the generated prompt there. If not, state that proactive notification is
|
|
100
|
-
not available in this environment, but the job can be recovered later with
|
|
101
|
-
`twinkler jobs active` and `twinkler jobs recent`.
|
|
106
|
+
Expected watch result shapes:
|
|
102
107
|
|
|
103
|
-
|
|
104
|
-
|
|
108
|
+
- `ccv_points` are live viewer samples. Preserve `login`, timestamp fields,
|
|
109
|
+
`viewer_count`, `game_id`, `game_name`, `title`, and `online`.
|
|
110
|
+
- `chat_messages` are captured chat rows. Preserve `jobId`, `login`,
|
|
111
|
+
`senderLogin`, `tmiSentTs`, `msgId`, `text`, `badges`, `color`, and `flags`.
|
|
112
|
+
- Job summaries include `accumulated.ccv_points`,
|
|
113
|
+
`accumulated.chat_messages`, and often `latest_ccv`.
|
|
105
114
|
|
|
106
115
|
## Data Processing Layer
|
|
107
116
|
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: setup-5mghost-twinkler
|
|
3
|
-
preamble-tier: 3
|
|
4
|
-
version: 0.2.0
|
|
5
|
-
description: |
|
|
6
|
-
Set up or repair the local Twinkler helper, bundled skills, and mkterswingman
|
|
7
|
-
auth for AI-driven Twitch data workflows.
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
# Setup 5mghost Twinkler
|
|
11
|
-
|
|
12
|
-
Use when Twinkler is missing, auth is missing or expired, skills may be stale,
|
|
13
|
-
or the user asks to prepare Twinkler for an AI workflow.
|
|
14
|
-
|
|
15
|
-
## Default Path
|
|
16
|
-
|
|
17
|
-
1. Check the helper:
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
twinkler doctor
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
2. If the helper is missing, install it:
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
npm install -g @mkterswingman/5mghost-twinkler
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
The npm install runs the bundled skill installer automatically. Use `twinkler
|
|
30
|
-
setup` only as a repair command when postinstall could not update the local AI
|
|
31
|
-
skill directories.
|
|
32
|
-
|
|
33
|
-
3. Repair skills when needed:
|
|
34
|
-
|
|
35
|
-
```bash
|
|
36
|
-
twinkler setup
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
4. If auth is missing, send the user to:
|
|
40
|
-
|
|
41
|
-
```text
|
|
42
|
-
https://mkterswingman.com/pat/login
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
Ask the user to copy the mkterswingman PAT, then start:
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
twinkler auth login --pat-stdin
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
Have the user paste the PAT into that local input. Do not ask them to paste the
|
|
52
|
-
PAT into chat, screenshots, docs, shell history, or source files.
|
|
53
|
-
|
|
54
|
-
## Success
|
|
55
|
-
|
|
56
|
-
`twinkler doctor` should report auth ready. Then the AI can use the
|
|
57
|
-
`use-5mghost-twinkler` skill and `twinkler call`.
|
|
58
|
-
|
|
59
|
-
## Recovery
|
|
60
|
-
|
|
61
|
-
- Missing command: run `npm install -g @mkterswingman/5mghost-twinkler`.
|
|
62
|
-
- Missing skills after install: run `twinkler setup`, then restart the AI session.
|
|
63
|
-
- Missing auth: use the PAT page and `twinkler auth login --pat-stdin`.
|
|
64
|
-
- HTTP 401/403 from API calls: run `twinkler auth status`; if stale, log in again.
|
|
65
|
-
- Permission or npm global install failure: use the same package manager and
|
|
66
|
-
Node installation method that originally installed global npm packages.
|
|
67
|
-
|
|
68
|
-
## Secret Policy
|
|
69
|
-
|
|
70
|
-
Never print, echo, log, commit, or store PATs in project files. The only normal
|
|
71
|
-
local storage is `~/.mkterswingman/auth.json`, written by the helper.
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
const args = parseArgs(process.argv.slice(2));
|
|
4
|
-
if (!args.jobId) {
|
|
5
|
-
console.error("Usage: create-watch-monitor.mjs --job-id <job_id> [--cadence-min 10] [--stop text] [--fields ccv_points,chat_messages,latest_ccv]");
|
|
6
|
-
process.exit(1);
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const cadenceMin = Number.parseInt(args.cadenceMin ?? "10", 10);
|
|
10
|
-
if (!Number.isFinite(cadenceMin) || cadenceMin < 1) {
|
|
11
|
-
console.error("--cadence-min must be a positive integer");
|
|
12
|
-
process.exit(1);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const fields = (args.fields ?? "ccv_points,chat_messages,latest_ccv")
|
|
16
|
-
.split(",")
|
|
17
|
-
.map((field) => field.trim())
|
|
18
|
-
.filter(Boolean);
|
|
19
|
-
const stop = args.stop ?? "the watch job completes, expires, is cancelled, the stream ends, or the requested game no longer matches";
|
|
20
|
-
const jobId = args.jobId;
|
|
21
|
-
|
|
22
|
-
const prompt = [
|
|
23
|
-
`Check the production 5mghost-twinkler watch job ${jobId}.`,
|
|
24
|
-
"Do not print secrets.",
|
|
25
|
-
"Run `twinkler ensure --auto-update --json` first; if auth is missing, ask the user to complete `twinkler login`.",
|
|
26
|
-
`Fetch \`twinkler jobs show ${jobId}\` and report status plus ${fields.join(", ")}.`,
|
|
27
|
-
"If the job is active, include latest online/game/title/viewer count when available.",
|
|
28
|
-
`Stop monitoring when ${stop}.`,
|
|
29
|
-
"If the job has completed, tell the user clearly that the monitor can be disabled."
|
|
30
|
-
].join(" ");
|
|
31
|
-
|
|
32
|
-
const payload = {
|
|
33
|
-
kind: "twinkler_watch_monitor",
|
|
34
|
-
job_id: jobId,
|
|
35
|
-
cadence_minutes: cadenceMin,
|
|
36
|
-
stop_condition: stop,
|
|
37
|
-
fields,
|
|
38
|
-
prompt
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
console.log(JSON.stringify(payload, null, 2));
|
|
42
|
-
|
|
43
|
-
function parseArgs(argv) {
|
|
44
|
-
const parsed = {};
|
|
45
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
46
|
-
const arg = argv[index];
|
|
47
|
-
if (arg === "--job-id") parsed.jobId = argv[++index];
|
|
48
|
-
else if (arg === "--cadence-min") parsed.cadenceMin = argv[++index];
|
|
49
|
-
else if (arg === "--stop") parsed.stop = argv[++index];
|
|
50
|
-
else if (arg === "--fields") parsed.fields = argv[++index];
|
|
51
|
-
else {
|
|
52
|
-
console.error(`Unknown option: ${arg}`);
|
|
53
|
-
process.exit(1);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return parsed;
|
|
57
|
-
}
|