@mkterswingman/5mghost-twinkler 0.1.3 → 0.1.5
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 +5 -3
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +119 -18
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +216 -20
- package/package.json +1 -1
- package/skills/update-5mghost-twinkler/SKILL.md +1 -2
- package/skills/use-5mghost-twinkler/SKILL.md +104 -82
- package/skills/use-5mghost-twinkler/scripts/create-watch-monitor.mjs +57 -0
- package/skills.manifest.json +0 -16
package/README.md
CHANGED
|
@@ -4,13 +4,15 @@ Lightweight AI runtime for the hosted Twinkler API.
|
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install -g @mkterswingman/5mghost-twinkler
|
|
7
|
+
twinkler ensure --auto-update --json
|
|
7
8
|
twinkler call GET /api/v1/channel/ibai/summary --query days=30
|
|
8
9
|
```
|
|
9
10
|
|
|
10
11
|
`npm install -g` installs bundled AI skills automatically for detected AI
|
|
11
|
-
clients. `twinkler setup` remains an
|
|
12
|
-
|
|
12
|
+
clients. `twinkler setup` remains an advanced repair command when a skill target
|
|
13
|
+
was unavailable during install; normal AI workflows should start with
|
|
14
|
+
`twinkler ensure --auto-update --json`.
|
|
13
15
|
|
|
14
16
|
Auth uses the shared mkterswingman PAT. Do not commit `.env`, `auth.json`,
|
|
15
17
|
PATs, bearer tokens, or any generated secret file to a remote repository. Enter
|
|
16
|
-
PATs through `twinkler
|
|
18
|
+
PATs through `twinkler login`; do not paste PATs into AI chat.
|
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
|
|
60
|
+
if (hasUsableAccessToken(auth, options.now)) {
|
|
58
61
|
return auth.access_token;
|
|
59
62
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
export interface CliContext {
|
|
3
4
|
argv?: string[];
|
|
4
5
|
env?: NodeJS.ProcessEnv;
|
|
@@ -7,5 +8,6 @@ export interface CliContext {
|
|
|
7
8
|
stderr?: (message: string) => void;
|
|
8
9
|
stdin?: AsyncIterable<Buffer | string>;
|
|
9
10
|
fetchImpl?: typeof fetch;
|
|
11
|
+
spawnSyncImpl?: typeof spawnSync;
|
|
10
12
|
}
|
|
11
13
|
export declare function runCli(context?: CliContext): Promise<number>;
|
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ 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
7
|
import { resolveTwinklerPaths } from "./paths.js";
|
|
8
8
|
import { installBundledSkills } from "./skillInstall.js";
|
|
9
9
|
import { callTwinkler } from "./twinklerClient.js";
|
|
@@ -11,19 +11,25 @@ const HELP = [
|
|
|
11
11
|
"twinkler",
|
|
12
12
|
"",
|
|
13
13
|
"Usage:",
|
|
14
|
-
" twinkler setup",
|
|
15
|
-
" twinkler install-skills",
|
|
16
14
|
" twinkler doctor",
|
|
15
|
+
" twinkler login",
|
|
16
|
+
" twinkler ensure [--auto-update] [--json]",
|
|
17
17
|
" twinkler auth status",
|
|
18
|
-
" twinkler auth login --pat <TOKEN>",
|
|
19
18
|
" twinkler auth login --pat-stdin",
|
|
20
19
|
" twinkler call <GET|POST|DELETE> /api/v1/... [--query k=v] [--json '{...}']",
|
|
20
|
+
" twinkler jobs active|recent|show <job_id>",
|
|
21
21
|
" twinkler update",
|
|
22
22
|
" twinkler version",
|
|
23
23
|
"",
|
|
24
|
+
"Repair / advanced:",
|
|
25
|
+
" twinkler setup",
|
|
26
|
+
" twinkler install-skills",
|
|
27
|
+
" twinkler auth login --pat <TOKEN>",
|
|
28
|
+
"",
|
|
24
29
|
"mkterswingman PAT:",
|
|
25
30
|
` ${PAT_LOGIN_URL}`
|
|
26
31
|
].join("\n");
|
|
32
|
+
const PACKAGE_NAME = "@mkterswingman/5mghost-twinkler";
|
|
27
33
|
export async function runCli(context = {}) {
|
|
28
34
|
const argv = context.argv ?? process.argv.slice(2);
|
|
29
35
|
const env = context.env ?? process.env;
|
|
@@ -45,7 +51,30 @@ export async function runCli(context = {}) {
|
|
|
45
51
|
out(readPackageVersion());
|
|
46
52
|
return 0;
|
|
47
53
|
case "auth":
|
|
48
|
-
return await runAuthCommand(args, {
|
|
54
|
+
return await runAuthCommand(args, {
|
|
55
|
+
paths,
|
|
56
|
+
env,
|
|
57
|
+
out,
|
|
58
|
+
stdin: context.stdin ?? process.stdin,
|
|
59
|
+
fetchImpl: context.fetchImpl
|
|
60
|
+
});
|
|
61
|
+
case "login":
|
|
62
|
+
return await runAuthCommand(["login", "--pat-stdin", ...args.filter((arg) => arg !== "--pat-stdin")], {
|
|
63
|
+
paths,
|
|
64
|
+
env,
|
|
65
|
+
out,
|
|
66
|
+
stdin: context.stdin ?? process.stdin,
|
|
67
|
+
fetchImpl: context.fetchImpl
|
|
68
|
+
});
|
|
69
|
+
case "ensure":
|
|
70
|
+
return await runEnsureCommand(args, {
|
|
71
|
+
paths,
|
|
72
|
+
env,
|
|
73
|
+
out,
|
|
74
|
+
err,
|
|
75
|
+
fetchImpl: context.fetchImpl,
|
|
76
|
+
spawnSyncImpl: context.spawnSyncImpl ?? spawnSync
|
|
77
|
+
});
|
|
49
78
|
case "call": {
|
|
50
79
|
const parsed = parseCallArgs(args);
|
|
51
80
|
const result = await callTwinkler({
|
|
@@ -57,6 +86,13 @@ export async function runCli(context = {}) {
|
|
|
57
86
|
out(JSON.stringify(result, null, 2));
|
|
58
87
|
return 0;
|
|
59
88
|
}
|
|
89
|
+
case "jobs":
|
|
90
|
+
return await runJobsCommand(args, {
|
|
91
|
+
paths,
|
|
92
|
+
env,
|
|
93
|
+
out,
|
|
94
|
+
fetchImpl: context.fetchImpl
|
|
95
|
+
});
|
|
60
96
|
case "install-skills": {
|
|
61
97
|
const results = installBundledSkills({ homeDir: paths.homeDir });
|
|
62
98
|
out(renderSkillInstallResults(results));
|
|
@@ -64,7 +100,7 @@ export async function runCli(context = {}) {
|
|
|
64
100
|
}
|
|
65
101
|
case "setup": {
|
|
66
102
|
const results = installBundledSkills({ homeDir: paths.homeDir });
|
|
67
|
-
const auth =
|
|
103
|
+
const auth = await getVerifiedAuthStatus(paths, env, context.fetchImpl);
|
|
68
104
|
out([
|
|
69
105
|
"Twinkler setup",
|
|
70
106
|
renderSkillInstallResults(results),
|
|
@@ -76,9 +112,9 @@ export async function runCli(context = {}) {
|
|
|
76
112
|
}
|
|
77
113
|
case "update":
|
|
78
114
|
case "upgrade":
|
|
79
|
-
return runUpdateCommand(args, { env, out, err });
|
|
115
|
+
return runUpdateCommand(args, { env, out, err, spawnSyncImpl: context.spawnSyncImpl ?? spawnSync });
|
|
80
116
|
case "doctor": {
|
|
81
|
-
const auth =
|
|
117
|
+
const auth = await getVerifiedAuthStatus(paths, env, context.fetchImpl);
|
|
82
118
|
out([
|
|
83
119
|
"Twinkler doctor",
|
|
84
120
|
`Node: ${process.version}`,
|
|
@@ -106,16 +142,18 @@ async function runAuthCommand(args, context) {
|
|
|
106
142
|
context.out([
|
|
107
143
|
"Usage:",
|
|
108
144
|
" twinkler auth status",
|
|
109
|
-
" twinkler auth login --pat <TOKEN>",
|
|
110
145
|
" twinkler auth login --pat-stdin",
|
|
111
146
|
" twinkler auth logout",
|
|
112
147
|
"",
|
|
148
|
+
"Advanced compatibility:",
|
|
149
|
+
" twinkler auth login --pat <TOKEN>",
|
|
150
|
+
"",
|
|
113
151
|
`PAT page: ${PAT_LOGIN_URL}`
|
|
114
152
|
].join("\n"));
|
|
115
153
|
return 0;
|
|
116
154
|
}
|
|
117
155
|
if (subcommand === "status") {
|
|
118
|
-
const status =
|
|
156
|
+
const status = await getVerifiedAuthStatus(context.paths, context.env, context.fetchImpl);
|
|
119
157
|
if (!status.authenticated) {
|
|
120
158
|
context.out([
|
|
121
159
|
"Not logged in.",
|
|
@@ -154,6 +192,148 @@ async function runAuthCommand(args, context) {
|
|
|
154
192
|
context.out(`Unknown auth subcommand: ${subcommand}`);
|
|
155
193
|
return 1;
|
|
156
194
|
}
|
|
195
|
+
async function runEnsureCommand(args, context) {
|
|
196
|
+
const json = args.includes("--json");
|
|
197
|
+
const autoUpdate = args.includes("--auto-update");
|
|
198
|
+
const localVersion = readPackageVersion();
|
|
199
|
+
const latest = readNpmLatestVersion(context.spawnSyncImpl, context.env);
|
|
200
|
+
const auth = await getVerifiedAuthStatus(context.paths, context.env, context.fetchImpl);
|
|
201
|
+
const updateNeeded = latest.version !== null && compareSemver(latest.version, localVersion) > 0;
|
|
202
|
+
let update = {
|
|
203
|
+
needed: updateNeeded,
|
|
204
|
+
attempted: false,
|
|
205
|
+
status: updateNeeded ? "skipped" : "not_needed"
|
|
206
|
+
};
|
|
207
|
+
let exitCode = 0;
|
|
208
|
+
if (updateNeeded && autoUpdate) {
|
|
209
|
+
const result = runGlobalNpmInstall({ env: context.env, spawnSyncImpl: context.spawnSyncImpl });
|
|
210
|
+
update = {
|
|
211
|
+
needed: true,
|
|
212
|
+
attempted: true,
|
|
213
|
+
status: result.ok ? "updated" : "failed",
|
|
214
|
+
command: result.command,
|
|
215
|
+
exit_code: result.exitCode,
|
|
216
|
+
error: result.error
|
|
217
|
+
};
|
|
218
|
+
exitCode = result.ok ? 0 : 1;
|
|
219
|
+
}
|
|
220
|
+
else if (latest.error) {
|
|
221
|
+
update = {
|
|
222
|
+
needed: false,
|
|
223
|
+
attempted: false,
|
|
224
|
+
status: "unknown",
|
|
225
|
+
error: latest.error
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const result = {
|
|
229
|
+
status: exitCode === 0 ? "ok" : "error",
|
|
230
|
+
package: PACKAGE_NAME,
|
|
231
|
+
local_version: localVersion,
|
|
232
|
+
latest_version: latest.version,
|
|
233
|
+
latest_check: latest.error ? { ok: false, error: latest.error } : { ok: true },
|
|
234
|
+
update,
|
|
235
|
+
auth: {
|
|
236
|
+
authenticated: auth.authenticated,
|
|
237
|
+
type: auth.type,
|
|
238
|
+
source: auth.source,
|
|
239
|
+
auth_file: auth.authJsonPath
|
|
240
|
+
},
|
|
241
|
+
ai_session_restart_required: update.status === "updated"
|
|
242
|
+
};
|
|
243
|
+
if (json) {
|
|
244
|
+
context.out(JSON.stringify(result, null, 2));
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
context.out(renderEnsureResult(result));
|
|
248
|
+
}
|
|
249
|
+
if (update.status === "failed" && update.error)
|
|
250
|
+
context.err(update.error);
|
|
251
|
+
return exitCode;
|
|
252
|
+
}
|
|
253
|
+
async function getVerifiedAuthStatus(paths, env, fetchImpl) {
|
|
254
|
+
const auth = getAuthStatus({ authJsonPath: paths.authJsonPath, env });
|
|
255
|
+
if (!auth.authenticated)
|
|
256
|
+
return auth;
|
|
257
|
+
const token = await getValidToken({ authJsonPath: paths.authJsonPath, env, fetchImpl });
|
|
258
|
+
return { ...auth, authenticated: Boolean(token) };
|
|
259
|
+
}
|
|
260
|
+
function renderEnsureResult(result) {
|
|
261
|
+
const latest = result.latest_version ?? `unknown (${result.latest_check.ok ? "not checked" : result.latest_check.error})`;
|
|
262
|
+
const lines = [
|
|
263
|
+
"Twinkler ensure",
|
|
264
|
+
`Package: ${result.package}`,
|
|
265
|
+
`Local version: ${result.local_version}`,
|
|
266
|
+
`Latest version: ${latest}`,
|
|
267
|
+
`Update: ${result.update.status}`,
|
|
268
|
+
`Auth: ${result.auth.authenticated ? `ready (${result.auth.type} via ${result.auth.source})` : "missing"}`
|
|
269
|
+
];
|
|
270
|
+
if (result.ai_session_restart_required) {
|
|
271
|
+
lines.push("AI session: restart recommended so updated skill text is loaded.");
|
|
272
|
+
}
|
|
273
|
+
return lines.join("\n");
|
|
274
|
+
}
|
|
275
|
+
function readNpmLatestVersion(spawnSyncImpl, env) {
|
|
276
|
+
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
277
|
+
const result = spawnSyncImpl(npmCommand, ["view", `${PACKAGE_NAME}@latest`, "version", "--silent"], {
|
|
278
|
+
encoding: "utf8",
|
|
279
|
+
env
|
|
280
|
+
});
|
|
281
|
+
if (result.error)
|
|
282
|
+
return { version: null, error: result.error.message };
|
|
283
|
+
if (result.status !== 0) {
|
|
284
|
+
return { version: null, error: (result.stderr || `npm exited with ${result.status ?? "unknown status"}`).trim() };
|
|
285
|
+
}
|
|
286
|
+
const version = result.stdout.trim();
|
|
287
|
+
return version ? { version } : { version: null, error: "npm returned an empty latest version" };
|
|
288
|
+
}
|
|
289
|
+
function compareSemver(a, b) {
|
|
290
|
+
const left = parseSemver(a);
|
|
291
|
+
const right = parseSemver(b);
|
|
292
|
+
for (let index = 0; index < 3; index += 1) {
|
|
293
|
+
if (left[index] !== right[index])
|
|
294
|
+
return left[index] > right[index] ? 1 : -1;
|
|
295
|
+
}
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
298
|
+
function parseSemver(version) {
|
|
299
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
|
|
300
|
+
if (!match)
|
|
301
|
+
return [0, 0, 0];
|
|
302
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
303
|
+
}
|
|
304
|
+
async function runJobsCommand(args, context) {
|
|
305
|
+
const [subcommand, value] = args.filter((arg) => arg !== "--json");
|
|
306
|
+
let path;
|
|
307
|
+
if (subcommand === "active") {
|
|
308
|
+
path = "/api/v1/twitch/watch";
|
|
309
|
+
}
|
|
310
|
+
else if (subcommand === "recent") {
|
|
311
|
+
path = "/api/v1/twitch/watch";
|
|
312
|
+
}
|
|
313
|
+
else if (subcommand === "show" && value) {
|
|
314
|
+
path = `/api/v1/twitch/watch/${encodeURIComponent(value)}`;
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
context.out([
|
|
318
|
+
"Usage:",
|
|
319
|
+
" twinkler jobs active",
|
|
320
|
+
" twinkler jobs recent",
|
|
321
|
+
" twinkler jobs show <job_id>"
|
|
322
|
+
].join("\n"));
|
|
323
|
+
return 1;
|
|
324
|
+
}
|
|
325
|
+
const query = subcommand === "active" ? { status: ["active"] } : subcommand === "recent" ? { status: ["recent"] } : {};
|
|
326
|
+
const result = await callTwinkler({
|
|
327
|
+
method: "GET",
|
|
328
|
+
path,
|
|
329
|
+
query,
|
|
330
|
+
authJsonPath: context.paths.authJsonPath,
|
|
331
|
+
env: context.env,
|
|
332
|
+
fetchImpl: context.fetchImpl
|
|
333
|
+
});
|
|
334
|
+
context.out(JSON.stringify(result, null, 2));
|
|
335
|
+
return 0;
|
|
336
|
+
}
|
|
157
337
|
function parseCallArgs(args) {
|
|
158
338
|
const [method, path, ...rest] = args;
|
|
159
339
|
if (!method || !path) {
|
|
@@ -196,26 +376,42 @@ function renderSkillInstallResults(results) {
|
|
|
196
376
|
function runUpdateCommand(args, context) {
|
|
197
377
|
const dryRun = args.includes("--dry-run") || context.env.TWINKLER_UPDATE_DRY_RUN === "1";
|
|
198
378
|
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
199
|
-
const installArgs = ["install", "-g",
|
|
379
|
+
const installArgs = ["install", "-g", `${PACKAGE_NAME}@latest`];
|
|
200
380
|
if (dryRun) {
|
|
201
381
|
context.out(`Update command: ${npmCommand} ${installArgs.join(" ")}`);
|
|
202
382
|
return 0;
|
|
203
383
|
}
|
|
204
384
|
context.out("Updating Twinkler helper...");
|
|
205
|
-
const result =
|
|
206
|
-
|
|
207
|
-
|
|
385
|
+
const result = runGlobalNpmInstall({ env: context.env, spawnSyncImpl: context.spawnSyncImpl, inheritStdio: true });
|
|
386
|
+
if (!result.ok) {
|
|
387
|
+
context.err(result.error ?? `Update failed: npm exited with ${result.exitCode ?? "unknown status"}`);
|
|
388
|
+
return result.exitCode ?? 1;
|
|
389
|
+
}
|
|
390
|
+
context.out("Twinkler updated. Restart your AI session so newly installed skill text is loaded.");
|
|
391
|
+
return 0;
|
|
392
|
+
}
|
|
393
|
+
function runGlobalNpmInstall(options) {
|
|
394
|
+
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
395
|
+
const installArgs = ["install", "-g", `${PACKAGE_NAME}@latest`];
|
|
396
|
+
const result = options.spawnSyncImpl(npmCommand, installArgs, {
|
|
397
|
+
stdio: options.inheritStdio ? "inherit" : "pipe",
|
|
398
|
+
encoding: options.inheritStdio ? undefined : "utf8",
|
|
399
|
+
env: options.env
|
|
208
400
|
});
|
|
401
|
+
const command = `${npmCommand} ${installArgs.join(" ")}`;
|
|
209
402
|
if (result.error) {
|
|
210
|
-
|
|
211
|
-
return 1;
|
|
403
|
+
return { ok: false, command, exitCode: null, error: `Update failed: ${result.error.message}` };
|
|
212
404
|
}
|
|
213
405
|
if (result.status !== 0) {
|
|
214
|
-
|
|
215
|
-
return
|
|
406
|
+
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
407
|
+
return {
|
|
408
|
+
ok: false,
|
|
409
|
+
command,
|
|
410
|
+
exitCode: result.status,
|
|
411
|
+
error: stderr || `Update failed: npm exited with ${result.status ?? "unknown status"}`
|
|
412
|
+
};
|
|
216
413
|
}
|
|
217
|
-
|
|
218
|
-
return 0;
|
|
414
|
+
return { ok: true, command, exitCode: 0 };
|
|
219
415
|
}
|
|
220
416
|
async function readAllStdin(stdin) {
|
|
221
417
|
const chunks = [];
|
package/package.json
CHANGED
|
@@ -26,7 +26,6 @@ If `twinkler update` is unavailable because the installed version is old, run:
|
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
28
|
npm install -g @mkterswingman/5mghost-twinkler@latest
|
|
29
|
-
twinkler setup
|
|
30
29
|
```
|
|
31
30
|
|
|
32
31
|
Then ask the user to restart the AI session so updated skill text is loaded.
|
|
@@ -35,7 +34,7 @@ Then ask the user to restart the AI session so updated skill text is loaded.
|
|
|
35
34
|
|
|
36
35
|
```bash
|
|
37
36
|
twinkler version
|
|
38
|
-
twinkler
|
|
37
|
+
twinkler ensure --json
|
|
39
38
|
```
|
|
40
39
|
|
|
41
40
|
Do not ask the user to edit skill directories or hidden config files manually.
|
|
@@ -1,137 +1,159 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: use-5mghost-twinkler
|
|
3
3
|
preamble-tier: 3
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
description: |
|
|
6
6
|
Use when the user wants Twitch streamer, game, ranking, stream-session, CCV,
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
chat, live-watch, or SullyGnome data through the mkterswingman Twinkler API.
|
|
8
|
+
Also use for vague requests such as "查这个主播", "盯这个直播", "拉 Twitch 数据",
|
|
9
|
+
"看看 CCV/chat", or "找符合条件的主播".
|
|
9
10
|
---
|
|
10
11
|
|
|
11
12
|
# Use 5mghost Twinkler
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
Operate Twinkler for the user. Do not ask non-technical users to run CLI
|
|
15
|
+
commands, remember job IDs, choose API routes, or design pagination strategy.
|
|
16
|
+
Use the local `twinkler` helper instead of hand-written `curl`; it handles
|
|
17
|
+
mkterswingman auth and only allows hosted Twinkler `/api/v1/*` paths.
|
|
16
18
|
|
|
17
|
-
##
|
|
19
|
+
## Required Preflight
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
Before any data work, run:
|
|
20
22
|
|
|
21
23
|
```bash
|
|
22
|
-
twinkler
|
|
24
|
+
twinkler ensure --auto-update --json
|
|
23
25
|
```
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
Do not ask non-technical users to understand CLI concepts; operate the helper
|
|
27
|
-
for them and explain only the action they need to take.
|
|
27
|
+
Read the JSON.
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
- If `update.status` is `updated`, continue the current task. Tell the user only
|
|
30
|
+
that the helper is updated and a new AI session is needed to load changed skill
|
|
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.
|
|
35
|
+
- If update failed but the current helper can still complete the task, continue
|
|
36
|
+
and mention the update failure briefly. Stop only when the helper is too old
|
|
37
|
+
for the requested command.
|
|
30
38
|
|
|
31
|
-
|
|
39
|
+
Do not use time-based latest-version caches. The preflight checks npm latest on
|
|
40
|
+
every invocation.
|
|
32
41
|
|
|
33
|
-
|
|
42
|
+
## Data Request Layer
|
|
34
43
|
|
|
35
|
-
|
|
36
|
-
2. Ask the user to copy the mkterswingman PAT.
|
|
37
|
-
3. Start `twinkler auth login --pat-stdin`.
|
|
38
|
-
4. Have the user paste the PAT into that local input, not into AI chat.
|
|
39
|
-
|
|
40
|
-
Never echo the PAT back to the user. Never put the PAT in source files, docs,
|
|
41
|
-
logs, URLs, screenshots, PR text, or committed `.env` files. Do not commit
|
|
42
|
-
`~/.mkterswingman/auth.json` or any generated secret/config file.
|
|
43
|
-
|
|
44
|
-
## Common Calls
|
|
45
|
-
|
|
46
|
-
Use:
|
|
44
|
+
Use `twinkler call` for one-off hosted API requests:
|
|
47
45
|
|
|
48
46
|
```bash
|
|
49
47
|
twinkler call GET /api/v1/channel/ibai/summary --query days=30
|
|
50
|
-
twinkler call GET /api/v1/
|
|
48
|
+
twinkler call GET /api/v1/channel/ibai/streams --query days=30 --query sort_by=avgviewers --query page=1 --query page_size=25
|
|
49
|
+
twinkler call GET /api/v1/game/Delta%20Force/channels --query days=365 --query sort_by=watched --query page=1 --query page_size=50
|
|
50
|
+
twinkler call GET /api/v1/rankings/channels --query sort_by=mostfollowers --query days=30 --query page=1 --query page_size=25
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
Pick the smallest API that answers the question:
|
|
54
|
+
|
|
55
|
+
- Channel profile/current history: `/channel/{name}/summary`.
|
|
56
|
+
- A channel's recent broadcasts: `/channel/{name}/streams`.
|
|
57
|
+
- A channel's games: `/channel/{name}/games`.
|
|
58
|
+
- Stream-session chart or game splits: `/channel/{name}/stream/{stream_id}/chart`
|
|
59
|
+
and `/games`.
|
|
60
|
+
- Game creator discovery: `/game/{game_identifier}/channels`.
|
|
61
|
+
- Broad Twitch leaderboards: `/rankings/channels`, `/rankings/games`,
|
|
62
|
+
`/rankings/teams`.
|
|
63
|
+
- Live CCV/chat sampling: create a watch job instead of polling summary pages.
|
|
64
|
+
|
|
65
|
+
## Watch Job Layer
|
|
66
|
+
|
|
67
|
+
Use watch jobs when the user asks to monitor a live channel, collect CCV points,
|
|
68
|
+
collect chat messages, or stop when the stream ends or changes game.
|
|
55
69
|
|
|
56
|
-
|
|
70
|
+
Before creating a new watch job in a new session, recover existing work:
|
|
57
71
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
```bash
|
|
73
|
+
twinkler jobs active
|
|
74
|
+
twinkler jobs recent
|
|
75
|
+
```
|
|
61
76
|
|
|
62
|
-
Create:
|
|
77
|
+
Create a job:
|
|
63
78
|
|
|
64
79
|
```bash
|
|
65
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"}}'
|
|
66
81
|
```
|
|
67
82
|
|
|
68
|
-
|
|
83
|
+
Then inspect it with:
|
|
69
84
|
|
|
70
85
|
```bash
|
|
71
|
-
twinkler
|
|
86
|
+
twinkler jobs show <job_id>
|
|
72
87
|
twinkler call GET /api/v1/twitch/watch/<job_id>/ccv
|
|
73
88
|
twinkler call GET /api/v1/twitch/watch/<job_id>/chat
|
|
74
89
|
```
|
|
75
90
|
|
|
76
|
-
|
|
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:
|
|
77
93
|
|
|
78
94
|
```bash
|
|
79
|
-
|
|
95
|
+
node scripts/create-watch-monitor.mjs --job-id <job_id> --cadence-min 10 --stop "stream ended or no longer matches requested game"
|
|
80
96
|
```
|
|
81
97
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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`.
|
|
86
102
|
|
|
87
|
-
|
|
103
|
+
Do not make the user remember or run the recovery commands. Use them yourself in
|
|
104
|
+
new sessions.
|
|
88
105
|
|
|
89
|
-
|
|
90
|
-
the helper instead of re-implementing auth:
|
|
106
|
+
## Data Processing Layer
|
|
91
107
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
import { promisify } from "node:util";
|
|
108
|
+
Short responses can be parsed directly from JSON. For multi-page or
|
|
109
|
+
more-than-100-row tasks, write raw rows before analysis:
|
|
95
110
|
|
|
96
|
-
|
|
111
|
+
- Prefer JSONL for append-only pulls and quick inspection.
|
|
112
|
+
- Prefer SQLite when sampling over time, joining channel/game rows, deduping,
|
|
113
|
+
or applying repeated filters.
|
|
114
|
+
- Keep raw records plus derived columns such as `avg_viewers`, `stream_hours`,
|
|
115
|
+
`watch_hours`, `language`, `game_name`, and source route.
|
|
116
|
+
- Preserve timestamps and route/query parameters so metric definitions remain
|
|
117
|
+
auditable.
|
|
97
118
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
"/api/v1/channel/ibai/summary",
|
|
102
|
-
"--query",
|
|
103
|
-
"days=30",
|
|
104
|
-
]);
|
|
119
|
+
For "take a point every 1 minute" live tasks, prefer a hosted watch job. If a
|
|
120
|
+
local ad hoc sampler is unavoidable, store one raw row per poll in JSONL/SQLite;
|
|
121
|
+
do not rely on chat context as the data store.
|
|
105
122
|
|
|
106
|
-
|
|
107
|
-
console.log(payload);
|
|
108
|
-
```
|
|
123
|
+
## Analysis Layer
|
|
109
124
|
|
|
110
|
-
|
|
111
|
-
|
|
125
|
+
Plan the upstream sort order before paginating. For compound filters, sort by a
|
|
126
|
+
proxy that satisfies multiple constraints.
|
|
112
127
|
|
|
113
|
-
|
|
128
|
+
Example: "近 3 年播过 Delta Force、CCV/avg viewers > 1000、stream hours > 30、英语区主播".
|
|
114
129
|
|
|
115
|
-
Use
|
|
116
|
-
|
|
130
|
+
Use game channels or relevant rankings sorted by watch hours/watched first, not
|
|
131
|
+
stream hours. Watch hours approximates viewers times duration, so high rows are
|
|
132
|
+
more likely to satisfy both viewer and duration thresholds. Sorting by stream
|
|
133
|
+
hours alone pushes many low-viewer marathon channels ahead and wastes pages.
|
|
117
134
|
|
|
118
|
-
|
|
119
|
-
import os
|
|
120
|
-
import requests
|
|
135
|
+
Stop conditions:
|
|
121
136
|
|
|
122
|
-
|
|
137
|
+
- Compute the minimum possible watched threshold when definitions are aligned:
|
|
138
|
+
`min_watch_hours = min_avg_viewers * min_stream_hours`.
|
|
139
|
+
- Once sorted watched/watch-hours values fall materially below that threshold
|
|
140
|
+
and several pages have produced no qualifying candidates, stop or ask before
|
|
141
|
+
continuing.
|
|
142
|
+
- If the API's metric names or time windows are ambiguous, say which field is
|
|
143
|
+
being used and verify with a small sample before scaling.
|
|
123
144
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
145
|
+
## Error And Recovery States
|
|
146
|
+
|
|
147
|
+
- Missing helper: install `@mkterswingman/5mghost-twinkler` globally with npm,
|
|
148
|
+
then rerun preflight.
|
|
149
|
+
- Auth missing/expired: run `twinkler login`.
|
|
150
|
+
- Update needed: preflight auto-updates; continue after it succeeds.
|
|
151
|
+
- Watch disabled: explain that the hosted watch worker is disabled right now.
|
|
152
|
+
- Upstream blocked/rate limited: report retry timing and do not loop blindly.
|
|
153
|
+
- Empty result: distinguish real empty from wrong ID, wrong sort, wrong locale,
|
|
154
|
+
or unsupported time window.
|
|
155
|
+
|
|
156
|
+
## Secret Policy
|
|
133
157
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
runtime's secret store yourself when tool access provides a safe secret-entry
|
|
137
|
-
mechanism. Do not ask the user to paste the PAT into chat.
|
|
158
|
+
Never print, echo, log, commit, screenshot, or store PATs in project files. The
|
|
159
|
+
normal local storage is `~/.mkterswingman/auth.json`, written by the helper.
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
}
|
package/skills.manifest.json
CHANGED
|
@@ -2,22 +2,6 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"product": "5mghost-twinkler",
|
|
4
4
|
"skills": [
|
|
5
|
-
{
|
|
6
|
-
"name": "setup-5mghost-twinkler",
|
|
7
|
-
"source": { "type": "local", "path": "./skills/setup-5mghost-twinkler" },
|
|
8
|
-
"targets": [
|
|
9
|
-
"claude",
|
|
10
|
-
"claude-internal",
|
|
11
|
-
"codex",
|
|
12
|
-
"codex-internal",
|
|
13
|
-
"gemini",
|
|
14
|
-
"gemini-internal",
|
|
15
|
-
"openclaw",
|
|
16
|
-
"workbuddy",
|
|
17
|
-
"codebuddy",
|
|
18
|
-
"agents"
|
|
19
|
-
]
|
|
20
|
-
},
|
|
21
5
|
{
|
|
22
6
|
"name": "use-5mghost-twinkler",
|
|
23
7
|
"source": { "type": "local", "path": "./skills/use-5mghost-twinkler" },
|