@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 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/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 --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";
@@ -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", "--pat-stdin", ...args.filter((arg) => arg !== "--pat-stdin")], {
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
- : `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."
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 --pat-stdin",
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
- `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'."
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
- context.out([
183
- "mkterswingman PAT is required.",
184
- `Open ${PAT_LOGIN_URL}, copy the PAT, then enter it into 'twinkler auth login --pat-stdin'. Do not paste PATs into chat.`
185
- ].join("\n"));
186
- 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;
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("&", "&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.5",
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
- }