@mkterswingman/5mghost-twinkler 0.1.4 → 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 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 uses the shared mkterswingman PAT. Do not commit `.env`, `auth.json`,
17
- PATs, bearer tokens, or any generated secret file to a remote repository. Enter
18
- PATs through `twinkler login`; do not paste PATs into AI chat.
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/auth.d.ts CHANGED
@@ -21,6 +21,8 @@ export interface TokenProviderOptions {
21
21
  env?: NodeJS.ProcessEnv;
22
22
  fetchImpl?: typeof fetch;
23
23
  authUrl?: string;
24
+ now?: () => number;
25
+ sleep?: (ms: number) => Promise<void>;
24
26
  }
25
27
  export declare function getAuthStatus(options: TokenProviderOptions): AuthStatus;
26
28
  export declare function getValidToken(options: TokenProviderOptions): Promise<string | null>;
package/dist/auth.js CHANGED
@@ -1,9 +1,12 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  export const MKTERSWINGMAN_PAT_ENV = "MKTERSWINGMAN_PAT";
4
4
  export const PAT_LOGIN_URL = "https://mkterswingman.com/pat/login";
5
5
  const DEFAULT_AUTH_URL = "https://mkterswingman.com";
6
6
  const REFRESH_SKEW_MS = 60_000;
7
+ const REFRESH_LOCK_STALE_MS = 30_000;
8
+ const REFRESH_LOCK_TIMEOUT_MS = 90_000;
9
+ const REFRESH_LOCK_POLL_MS = 50;
7
10
  export function getAuthStatus(options) {
8
11
  if (options.env?.[MKTERSWINGMAN_PAT_ENV]) {
9
12
  return {
@@ -54,26 +57,35 @@ export async function getValidToken(options) {
54
57
  return null;
55
58
  if (auth.type === "pat")
56
59
  return auth.pat || null;
57
- if (auth.access_token && auth.expires_at && Date.now() + REFRESH_SKEW_MS < auth.expires_at) {
60
+ if (hasUsableAccessToken(auth, options.now)) {
58
61
  return auth.access_token;
59
62
  }
60
- if (!auth.refresh_token)
61
- return null;
62
- const refreshed = await refreshJwt(auth.refresh_token, {
63
- clientId: auth.client_id,
64
- authUrl: options.authUrl,
65
- fetchImpl: options.fetchImpl
66
- });
67
- if (!refreshed)
68
- return null;
69
- writeSharedAuth(options.authJsonPath, {
70
- type: "jwt",
71
- access_token: refreshed.access_token,
72
- refresh_token: refreshed.refresh_token,
73
- expires_at: Date.now() + refreshed.expires_in * 1000,
74
- client_id: auth.client_id
63
+ return withRefreshLock(options, async () => {
64
+ const latest = readSharedAuth(options.authJsonPath);
65
+ if (!latest)
66
+ return null;
67
+ if (latest.type === "pat")
68
+ return latest.pat || null;
69
+ if (hasUsableAccessToken(latest, options.now))
70
+ return latest.access_token;
71
+ if (!latest.refresh_token)
72
+ return null;
73
+ const refreshed = await refreshJwt(latest.refresh_token, {
74
+ clientId: latest.client_id,
75
+ authUrl: options.authUrl,
76
+ fetchImpl: options.fetchImpl
77
+ });
78
+ if (!refreshed)
79
+ return null;
80
+ writeSharedAuth(options.authJsonPath, {
81
+ type: "jwt",
82
+ access_token: refreshed.access_token,
83
+ refresh_token: refreshed.refresh_token,
84
+ expires_at: (options.now ?? Date.now)() + refreshed.expires_in * 1000,
85
+ client_id: latest.client_id
86
+ });
87
+ return refreshed.access_token;
75
88
  });
76
- return refreshed.access_token;
77
89
  }
78
90
  export function savePat(authJsonPath, pat) {
79
91
  const normalized = pat.trim();
@@ -112,6 +124,95 @@ export function writeSharedAuth(authJsonPath, data) {
112
124
  // Windows may ignore POSIX modes.
113
125
  }
114
126
  }
127
+ function hasUsableAccessToken(auth, now) {
128
+ return Boolean(auth.access_token &&
129
+ auth.expires_at &&
130
+ (now ?? Date.now)() + REFRESH_SKEW_MS < auth.expires_at);
131
+ }
132
+ async function withRefreshLock(options, fn) {
133
+ const lockPath = `${options.authJsonPath}.refresh.lock`;
134
+ const now = options.now ?? Date.now;
135
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
136
+ const deadline = now() + REFRESH_LOCK_TIMEOUT_MS;
137
+ mkdirSync(dirname(options.authJsonPath), { recursive: true });
138
+ while (true) {
139
+ try {
140
+ mkdirSync(lockPath, { mode: 0o700 });
141
+ try {
142
+ writeLockOwner(lockPath, now());
143
+ }
144
+ catch (error) {
145
+ rmSync(lockPath, { recursive: true, force: true });
146
+ throw error;
147
+ }
148
+ try {
149
+ return await fn();
150
+ }
151
+ finally {
152
+ rmSync(lockPath, { recursive: true, force: true });
153
+ }
154
+ }
155
+ catch (error) {
156
+ if (!isAlreadyExistsError(error))
157
+ throw error;
158
+ if (canRecoverRefreshLock(lockPath, now())) {
159
+ rmSync(lockPath, { recursive: true, force: true });
160
+ continue;
161
+ }
162
+ if (now() >= deadline) {
163
+ throw new Error("Timed out waiting for mkterswingman auth refresh lock");
164
+ }
165
+ await sleep(REFRESH_LOCK_POLL_MS);
166
+ }
167
+ }
168
+ }
169
+ function isAlreadyExistsError(error) {
170
+ return (typeof error === "object" &&
171
+ error !== null &&
172
+ "code" in error &&
173
+ error.code === "EEXIST");
174
+ }
175
+ function writeLockOwner(lockPath, createdAtMs) {
176
+ writeFileSync(`${lockPath}/owner.json`, JSON.stringify({ pid: process.pid, created_at: createdAtMs }), { encoding: "utf8", mode: 0o600 });
177
+ }
178
+ function canRecoverRefreshLock(lockPath, nowMs) {
179
+ const owner = readRefreshLockOwner(lockPath);
180
+ if (owner) {
181
+ return nowMs - owner.created_at > REFRESH_LOCK_STALE_MS && !isProcessAlive(owner.pid);
182
+ }
183
+ try {
184
+ return nowMs - statSync(lockPath).mtimeMs > REFRESH_LOCK_STALE_MS;
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ }
190
+ function readRefreshLockOwner(lockPath) {
191
+ try {
192
+ const owner = JSON.parse(readFileSync(`${lockPath}/owner.json`, "utf8"));
193
+ if (typeof owner.pid === "number" && typeof owner.created_at === "number") {
194
+ return { pid: owner.pid, created_at: owner.created_at };
195
+ }
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ return null;
201
+ }
202
+ function isProcessAlive(pid) {
203
+ if (!Number.isInteger(pid) || pid <= 0)
204
+ return false;
205
+ try {
206
+ process.kill(pid, 0);
207
+ return true;
208
+ }
209
+ catch (error) {
210
+ return (typeof error === "object" &&
211
+ error !== null &&
212
+ "code" in error &&
213
+ error.code === "EPERM");
214
+ }
215
+ }
115
216
  async function refreshJwt(refreshToken, options) {
116
217
  const fetchImpl = options.fetchImpl ?? fetch;
117
218
  const response = await fetchImpl(`${options.authUrl ?? DEFAULT_AUTH_URL}/oauth/token`, {
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
@@ -3,7 +3,9 @@ import { readFileSync, realpathSync } from "node:fs";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { getAuthStatus, logout, PAT_LOGIN_URL, savePat } from "./auth.js";
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 --pat-stdin",
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
- "mkterswingman PAT:",
33
+ "PAT fallback:",
30
34
  ` ${PAT_LOGIN_URL}`
31
35
  ].join("\n");
32
36
  const PACKAGE_NAME = "@mkterswingman/5mghost-twinkler";
@@ -51,20 +55,30 @@ export async function runCli(context = {}) {
51
55
  out(readPackageVersion());
52
56
  return 0;
53
57
  case "auth":
54
- return await runAuthCommand(args, { paths, env, out, stdin: context.stdin ?? process.stdin });
58
+ return await runAuthCommand(args, {
59
+ paths,
60
+ env,
61
+ out,
62
+ stdin: context.stdin ?? process.stdin,
63
+ fetchImpl: context.fetchImpl,
64
+ openUrlImpl: context.openUrlImpl
65
+ });
55
66
  case "login":
56
- return await runAuthCommand(["login", "--pat-stdin", ...args.filter((arg) => arg !== "--pat-stdin")], {
67
+ return await runAuthCommand(["login", ...args], {
57
68
  paths,
58
69
  env,
59
70
  out,
60
- stdin: context.stdin ?? process.stdin
71
+ stdin: context.stdin ?? process.stdin,
72
+ fetchImpl: context.fetchImpl,
73
+ openUrlImpl: context.openUrlImpl
61
74
  });
62
75
  case "ensure":
63
- return runEnsureCommand(args, {
76
+ return await runEnsureCommand(args, {
64
77
  paths,
65
78
  env,
66
79
  out,
67
80
  err,
81
+ fetchImpl: context.fetchImpl,
68
82
  spawnSyncImpl: context.spawnSyncImpl ?? spawnSync
69
83
  });
70
84
  case "call": {
@@ -75,6 +89,7 @@ export async function runCli(context = {}) {
75
89
  env,
76
90
  fetchImpl: context.fetchImpl
77
91
  });
92
+ reconcileCallResult(paths.jobsLedgerPath, result, parsed);
78
93
  out(JSON.stringify(result, null, 2));
79
94
  return 0;
80
95
  }
@@ -92,13 +107,13 @@ export async function runCli(context = {}) {
92
107
  }
93
108
  case "setup": {
94
109
  const results = installBundledSkills({ homeDir: paths.homeDir });
95
- const auth = getAuthStatus({ authJsonPath: paths.authJsonPath, env });
110
+ const auth = await getVerifiedAuthStatus(paths, env, context.fetchImpl);
96
111
  out([
97
112
  "Twinkler setup",
98
113
  renderSkillInstallResults(results),
99
114
  auth.authenticated
100
115
  ? `Auth: ready (${auth.type} via ${auth.source})`
101
- : `Auth: missing. Ask the user to open ${PAT_LOGIN_URL}, then enter the mkterswingman PAT into 'twinkler auth login --pat-stdin'. Do not paste PATs into chat.`
116
+ : "Auth: missing. Run 'twinkler login' and complete the browser login."
102
117
  ].join("\n"));
103
118
  return results.some((result) => result.status === "error") ? 1 : 0;
104
119
  }
@@ -106,7 +121,7 @@ export async function runCli(context = {}) {
106
121
  case "upgrade":
107
122
  return runUpdateCommand(args, { env, out, err, spawnSyncImpl: context.spawnSyncImpl ?? spawnSync });
108
123
  case "doctor": {
109
- const auth = getAuthStatus({ authJsonPath: paths.authJsonPath, env });
124
+ const auth = await getVerifiedAuthStatus(paths, env, context.fetchImpl);
110
125
  out([
111
126
  "Twinkler doctor",
112
127
  `Node: ${process.version}`,
@@ -134,10 +149,11 @@ async function runAuthCommand(args, context) {
134
149
  context.out([
135
150
  "Usage:",
136
151
  " twinkler auth status",
137
- " twinkler auth login --pat-stdin",
152
+ " twinkler auth login",
138
153
  " twinkler auth logout",
139
154
  "",
140
- "Advanced compatibility:",
155
+ "Advanced PAT compatibility:",
156
+ " twinkler auth login --pat-stdin",
141
157
  " twinkler auth login --pat <TOKEN>",
142
158
  "",
143
159
  `PAT page: ${PAT_LOGIN_URL}`
@@ -145,11 +161,11 @@ async function runAuthCommand(args, context) {
145
161
  return 0;
146
162
  }
147
163
  if (subcommand === "status") {
148
- const status = getAuthStatus({ authJsonPath: context.paths.authJsonPath, env: context.env });
164
+ const status = await getVerifiedAuthStatus(context.paths, context.env, context.fetchImpl);
149
165
  if (!status.authenticated) {
150
166
  context.out([
151
167
  "Not logged in.",
152
- `Open ${PAT_LOGIN_URL}, copy the mkterswingman PAT, and enter it into 'twinkler auth login --pat-stdin'. Do not paste PATs into chat.`
168
+ "Run 'twinkler login' and complete the browser login. Advanced fallback: 'twinkler login --pat-stdin'."
153
169
  ].join("\n"));
154
170
  return 1;
155
171
  }
@@ -169,14 +185,16 @@ async function runAuthCommand(args, context) {
169
185
  if (subcommand === "login") {
170
186
  const patFlagIndex = args.indexOf("--pat");
171
187
  const patStdin = args.includes("--pat-stdin");
172
- const token = patStdin ? await readAllStdin(context.stdin) : args[patFlagIndex + 1];
173
188
  if (patFlagIndex === -1 && !patStdin) {
174
- context.out([
175
- "mkterswingman PAT is required.",
176
- `Open ${PAT_LOGIN_URL}, copy the PAT, then enter it into 'twinkler auth login --pat-stdin'. Do not paste PATs into chat.`
177
- ].join("\n"));
178
- return 1;
189
+ await runOAuthLogin({
190
+ authJsonPath: context.paths.authJsonPath,
191
+ fetchImpl: context.fetchImpl,
192
+ openUrlImpl: context.openUrlImpl,
193
+ out: context.out
194
+ });
195
+ return 0;
179
196
  }
197
+ const token = patStdin ? await readAllStdin(context.stdin) : args[patFlagIndex + 1];
180
198
  savePat(context.paths.authJsonPath, token ?? "");
181
199
  context.out("mkterswingman PAT saved.");
182
200
  return 0;
@@ -184,12 +202,12 @@ async function runAuthCommand(args, context) {
184
202
  context.out(`Unknown auth subcommand: ${subcommand}`);
185
203
  return 1;
186
204
  }
187
- function runEnsureCommand(args, context) {
205
+ async function runEnsureCommand(args, context) {
188
206
  const json = args.includes("--json");
189
207
  const autoUpdate = args.includes("--auto-update");
190
208
  const localVersion = readPackageVersion();
191
209
  const latest = readNpmLatestVersion(context.spawnSyncImpl, context.env);
192
- const auth = getAuthStatus({ authJsonPath: context.paths.authJsonPath, env: context.env });
210
+ const auth = await getVerifiedAuthStatus(context.paths, context.env, context.fetchImpl);
193
211
  const updateNeeded = latest.version !== null && compareSemver(latest.version, localVersion) > 0;
194
212
  let update = {
195
213
  needed: updateNeeded,
@@ -242,6 +260,13 @@ function runEnsureCommand(args, context) {
242
260
  context.err(update.error);
243
261
  return exitCode;
244
262
  }
263
+ async function getVerifiedAuthStatus(paths, env, fetchImpl) {
264
+ const auth = getAuthStatus({ authJsonPath: paths.authJsonPath, env });
265
+ if (!auth.authenticated)
266
+ return auth;
267
+ const token = await getValidToken({ authJsonPath: paths.authJsonPath, env, fetchImpl });
268
+ return { ...auth, authenticated: Boolean(token) };
269
+ }
245
270
  function renderEnsureResult(result) {
246
271
  const latest = result.latest_version ?? `unknown (${result.latest_check.ok ? "not checked" : result.latest_check.error})`;
247
272
  const lines = [
@@ -289,6 +314,10 @@ function parseSemver(version) {
289
314
  async function runJobsCommand(args, context) {
290
315
  const [subcommand, value] = args.filter((arg) => arg !== "--json");
291
316
  let path;
317
+ if (subcommand === "ledger" || subcommand === "local") {
318
+ context.out(JSON.stringify(readJobLedger(context.paths.jobsLedgerPath), null, 2));
319
+ return 0;
320
+ }
292
321
  if (subcommand === "active") {
293
322
  path = "/api/v1/twitch/watch";
294
323
  }
@@ -303,7 +332,8 @@ async function runJobsCommand(args, context) {
303
332
  "Usage:",
304
333
  " twinkler jobs active",
305
334
  " twinkler jobs recent",
306
- " twinkler jobs show <job_id>"
335
+ " twinkler jobs show <job_id>",
336
+ " twinkler jobs ledger"
307
337
  ].join("\n"));
308
338
  return 1;
309
339
  }
@@ -316,6 +346,7 @@ async function runJobsCommand(args, context) {
316
346
  env: context.env,
317
347
  fetchImpl: context.fetchImpl
318
348
  });
349
+ reconcileWatchLedger(context.paths.jobsLedgerPath, result, { source: `jobs ${subcommand}` });
319
350
  context.out(JSON.stringify(result, null, 2));
320
351
  return 0;
321
352
  }
@@ -326,6 +357,7 @@ function parseCallArgs(args) {
326
357
  }
327
358
  const query = {};
328
359
  let jsonBody;
360
+ let userIntent = null;
329
361
  for (let index = 0; index < rest.length; index += 1) {
330
362
  const arg = rest[index];
331
363
  if (arg === "--query") {
@@ -345,9 +377,26 @@ function parseCallArgs(args) {
345
377
  jsonBody = JSON.parse(raw.startsWith("@") ? readFileSync(raw.slice(1), "utf8") : raw);
346
378
  continue;
347
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
+ }
348
387
  throw new Error(`Unknown call option: ${arg}`);
349
388
  }
350
- 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
+ });
351
400
  }
352
401
  function renderSkillInstallResults(results) {
353
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("&", "&amp;")
243
+ .replaceAll("<", "&lt;")
244
+ .replaceAll(">", "&gt;")
245
+ .replaceAll('"', "&quot;")
246
+ .replaceAll("'", "&#39;");
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
  }
@@ -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. Ask the user to open https://mkterswingman.com/pat/login, then save the PAT with 'twinkler auth login --pat-stdin' or set ${MKTERSWINGMAN_PAT_ENV}.`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mkterswingman/5mghost-twinkler",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Lightweight AI helper for the 5mghost Twinkler API",
5
5
  "type": "module",
6
6
  "engines": {
@@ -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`, give the user
33
- <https://mkterswingman.com/pat/login>, and have them paste the PAT into local
34
- stdin. Never ask for the PAT in chat.
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
- For long-running jobs, create a monitor in the AI environment. Generate the
92
- portable monitor payload with the bundled script in this skill directory:
93
-
94
- ```bash
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
- If the current runtime has an automation, heartbeat, reminder, or monitor tool,
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
- Do not make the user remember or run the recovery commands. Use them yourself in
104
- new sessions.
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
- }