shakers-cli-beta 0.8.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +59 -0
- package/README.md +1175 -0
- package/bin/add-agent.js +88 -0
- package/bin/add-project.js +88 -0
- package/bin/add-role.js +61 -0
- package/bin/add-skill.js +88 -0
- package/bin/ai-usage.js +81 -0
- package/bin/alma.js +99 -0
- package/bin/applications.js +69 -0
- package/bin/availability.js +70 -0
- package/bin/certifications.js +69 -0
- package/bin/certify.js +73 -0
- package/bin/change-role.js +61 -0
- package/bin/config.js +120 -0
- package/bin/experiences.js +69 -0
- package/bin/find-projects.js +89 -0
- package/bin/invitations.js +69 -0
- package/bin/lang.js +70 -0
- package/bin/login.js +283 -0
- package/bin/mcp.js +98 -0
- package/bin/me.js +69 -0
- package/bin/onboarding.js +134 -0
- package/bin/portfolios.js +69 -0
- package/bin/rate.js +70 -0
- package/bin/register.js +325 -0
- package/bin/report-html.js +134 -0
- package/bin/save.js +77 -0
- package/bin/shakers.js +344 -0
- package/bin/share.js +72 -0
- package/bin/show-project.js +72 -0
- package/bin/socials.js +70 -0
- package/bin/superadmin.js +351 -0
- package/package.json +40 -0
- package/src/agent-category.js +13 -0
- package/src/agent-evaluation.js +177 -0
- package/src/agent-org-chart.js +355 -0
- package/src/agent-synthesis.js +132 -0
- package/src/agent-usage.js +118 -0
- package/src/agents-client.js +165 -0
- package/src/ai-fluency-cell.js +72 -0
- package/src/ai-profile-client.js +45 -0
- package/src/ai-profile-preview.js +154 -0
- package/src/ai.js +66 -0
- package/src/alma-client.js +123 -0
- package/src/alma-flow.js +32 -0
- package/src/alma-repo-context.js +47 -0
- package/src/alma-shell.js +161 -0
- package/src/ansi.js +62 -0
- package/src/applications-client.js +79 -0
- package/src/applications-flow.js +58 -0
- package/src/auth-client.js +144 -0
- package/src/auth-device-client.js +118 -0
- package/src/auth-method-picker.js +44 -0
- package/src/auth-methods.js +35 -0
- package/src/auth-session-store.js +214 -0
- package/src/authorship.js +203 -0
- package/src/automations-detector.js +170 -0
- package/src/availability-client.js +92 -0
- package/src/availability-flow.js +105 -0
- package/src/backend-request.js +170 -0
- package/src/blind-approval-extract.js +48 -0
- package/src/brand-ansi.js +14 -0
- package/src/brand-svg.js +9 -0
- package/src/browser-tools-detector.js +21 -0
- package/src/build-next-level.js +56 -0
- package/src/certifications-client.js +89 -0
- package/src/certifications-flow.js +75 -0
- package/src/certify-dimension-client.js +217 -0
- package/src/certify-dimension-flow.js +193 -0
- package/src/certify-disclaimer.js +51 -0
- package/src/certify-remediation-prompt.js +23 -0
- package/src/cli-args.js +83 -0
- package/src/config-dir.js +120 -0
- package/src/config.js +778 -0
- package/src/consent-flow.js +115 -0
- package/src/consent-skip.js +27 -0
- package/src/decisions-extract.js +43 -0
- package/src/detect-repos.js +145 -0
- package/src/detected-tools.js +15 -0
- package/src/detection-evidence.js +105 -0
- package/src/detection-noise.js +132 -0
- package/src/detectors.js +221 -0
- package/src/device-auth-store.js +100 -0
- package/src/device-login.js +118 -0
- package/src/email-verification.js +152 -0
- package/src/env-paths.js +16 -0
- package/src/find-projects-cache.js +89 -0
- package/src/find-projects-client.js +138 -0
- package/src/find-projects-flow.js +164 -0
- package/src/git-activity.js +123 -0
- package/src/graph-certs.js +58 -0
- package/src/graph-generator.js +230 -0
- package/src/graph-scan.js +239 -0
- package/src/i18n.js +3264 -0
- package/src/inter-fonts.js +6 -0
- package/src/interactive-select.js +183 -0
- package/src/interview-client.js +226 -0
- package/src/interview-transcript.js +56 -0
- package/src/inventory-client.js +90 -0
- package/src/invitations-client.js +66 -0
- package/src/invitations-flow.js +50 -0
- package/src/lang-client.js +147 -0
- package/src/lang-codes.js +14 -0
- package/src/lang-flow.js +85 -0
- package/src/legal-notice.js +53 -0
- package/src/locale.js +51 -0
- package/src/markdown-ansi.js +50 -0
- package/src/maturity.js +111 -0
- package/src/mcp-ai-usage-result-tool.js +109 -0
- package/src/mcp-ai-usage-tool.js +333 -0
- package/src/mcp-alma-tools.js +91 -0
- package/src/mcp-applications-tools.js +42 -0
- package/src/mcp-ask-tools.js +50 -0
- package/src/mcp-auth-form.js +282 -0
- package/src/mcp-availability-tools.js +43 -0
- package/src/mcp-certifications-tools.js +42 -0
- package/src/mcp-certify-dimension-tools.js +58 -0
- package/src/mcp-detector.js +223 -0
- package/src/mcp-device-login-tools.js +173 -0
- package/src/mcp-find-projects-tools.js +67 -0
- package/src/mcp-install.js +245 -0
- package/src/mcp-invitations-tools.js +42 -0
- package/src/mcp-lang-tools.js +39 -0
- package/src/mcp-onboarding-tools.js +388 -0
- package/src/mcp-portfolios-tools.js +46 -0
- package/src/mcp-profile-tools.js +46 -0
- package/src/mcp-rate-tools.js +42 -0
- package/src/mcp-register-tools.js +517 -0
- package/src/mcp-report-tools.js +101 -0
- package/src/mcp-role-tools.js +112 -0
- package/src/mcp-saved-positions-tools.js +55 -0
- package/src/mcp-server.js +170 -0
- package/src/mcp-show-project-tools.js +50 -0
- package/src/mcp-socials-tools.js +39 -0
- package/src/memory-structure-detector.js +108 -0
- package/src/model-call-record.js +198 -0
- package/src/onboarding-availability.js +26 -0
- package/src/onboarding-client.js +364 -0
- package/src/onboarding-flow.js +1078 -0
- package/src/onboarding-livekit-client.js +102 -0
- package/src/onboarding-status-client.js +42 -0
- package/src/open-file.js +32 -0
- package/src/osc-link.js +16 -0
- package/src/portfolio-client.js +164 -0
- package/src/portfolio-git-info.js +61 -0
- package/src/portfolios-client.js +85 -0
- package/src/portfolios-flow.js +79 -0
- package/src/profile-client.js +125 -0
- package/src/profile-flow.js +115 -0
- package/src/profile-switch.js +46 -0
- package/src/prompt-select.js +29 -0
- package/src/rate-client.js +82 -0
- package/src/rate-flow.js +137 -0
- package/src/render-ai-fluency.js +266 -0
- package/src/render-certification.js +177 -0
- package/src/render-html.js +140 -0
- package/src/render-report-text.js +27 -0
- package/src/render-sheet.js +261 -0
- package/src/render-terminal.js +109 -0
- package/src/render-usage-report-text.js +155 -0
- package/src/repo-context.js +215 -0
- package/src/report-gating.js +49 -0
- package/src/report-store.js +226 -0
- package/src/report-theme.js +177 -0
- package/src/report-view-client.js +50 -0
- package/src/reveal-output.js +29 -0
- package/src/roadmap-content.js +453 -0
- package/src/roadmap-personalization.js +132 -0
- package/src/roadmap-prompt.js +154 -0
- package/src/roles-client.js +114 -0
- package/src/roles-flow.js +192 -0
- package/src/sanitize-network-text.js +36 -0
- package/src/saved-positions-client.js +71 -0
- package/src/saved-positions-flow.js +50 -0
- package/src/scan-exclusions.js +50 -0
- package/src/scanner.js +612 -0
- package/src/scope-signals.js +148 -0
- package/src/secret-prompt.js +72 -0
- package/src/session-gate.js +30 -0
- package/src/session-refresh.js +51 -0
- package/src/session-scan.js +336 -0
- package/src/session-signals.js +36 -0
- package/src/shakers-wordmark.js +17 -0
- package/src/share-card.js +362 -0
- package/src/share-certification.js +111 -0
- package/src/share-consent-state.js +157 -0
- package/src/share-payload.js +300 -0
- package/src/share.js +184 -0
- package/src/show-project-client.js +58 -0
- package/src/show-project-flow.js +119 -0
- package/src/signup-client.js +91 -0
- package/src/skills-client.js +102 -0
- package/src/socials-client.js +91 -0
- package/src/socials-flow.js +83 -0
- package/src/specificity-extract.js +41 -0
- package/src/start-add-agents.js +306 -0
- package/src/start-add-portfolio.js +388 -0
- package/src/start-add-skills.js +260 -0
- package/src/stdin-ask.js +129 -0
- package/src/steering-extract.js +43 -0
- package/src/strip-internal-comments.js +45 -0
- package/src/tech-detector.js +295 -0
- package/src/templates/report-sheet.html +572 -0
- package/src/terminal-agents.js +199 -0
- package/src/terminal-format.js +85 -0
- package/src/terminal-progress.js +106 -0
- package/src/terminal-sections.js +219 -0
- package/src/tier-analysis.js +126 -0
- package/src/tier-engine.js +114 -0
- package/src/tier-projection.js +117 -0
- package/src/transcript-turns.js +49 -0
- package/src/translations-client.js +84 -0
- package/src/typewriter.js +154 -0
- package/src/usage-model-calls.js +119 -0
- package/src/usage-oneshot-commands.js +180 -0
- package/src/usage-repo-scope.js +162 -0
- package/src/usage-run-steps.js +338 -0
- package/src/work-streams.js +74 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { runInteractiveMultiSelect } = require('./interactive-select');
|
|
4
|
+
const { methodsForFlow, methodLabel } = require('./auth-methods');
|
|
5
|
+
|
|
6
|
+
// The shared interactive method picker for the CLI (talents-ai-score).
|
|
7
|
+
async function askNumberedChoice(ask, promptText, count) {
|
|
8
|
+
const raw = (await ask(promptText)).trim();
|
|
9
|
+
if (!raw) return null;
|
|
10
|
+
const n = Number.parseInt(raw, 10);
|
|
11
|
+
if (!Number.isInteger(n) || n < 1 || n > count) return null;
|
|
12
|
+
return n - 1;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function chooseAuthMethod({ flow, ask, stdinIsTTY, catalog, input, output } = {}) {
|
|
16
|
+
const methods = methodsForFlow(flow);
|
|
17
|
+
if (methods.length === 0) return null;
|
|
18
|
+
if (methods.length === 1) return methods[0].id;
|
|
19
|
+
if (!stdinIsTTY) return methods[0].id;
|
|
20
|
+
|
|
21
|
+
if (ask && typeof ask.suspend === 'function') {
|
|
22
|
+
ask.suspend();
|
|
23
|
+
const picked = await runInteractiveMultiSelect({
|
|
24
|
+
items: methods,
|
|
25
|
+
labelFor: (m) => methodLabel(m, catalog),
|
|
26
|
+
header: catalog.chooseMethodHeading,
|
|
27
|
+
hint: catalog.selectHint,
|
|
28
|
+
single: true,
|
|
29
|
+
...(input ? { input } : {}),
|
|
30
|
+
...(output ? { output } : {}),
|
|
31
|
+
});
|
|
32
|
+
if (typeof ask.resume === 'function') ask.resume();
|
|
33
|
+
if (!picked || picked.length === 0) return null;
|
|
34
|
+
return picked[0].id;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const stream = output || process.stdout;
|
|
38
|
+
stream.write(`\n ${catalog.chooseMethodHeading}\n`);
|
|
39
|
+
methods.forEach((m, i) => stream.write(` ${i + 1}) ${methodLabel(m, catalog)}\n`));
|
|
40
|
+
const idx = await askNumberedChoice(ask, ` ${catalog.chooseMethodPrompt(methods.length)}`, methods.length);
|
|
41
|
+
return idx === null ? null : methods[idx].id;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { chooseAuthMethod, askNumberedChoice };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The SINGLE source of truth for which auth methods exist and where each one is offered (talents-ai-score).
|
|
4
|
+
const AUTH_METHODS = [
|
|
5
|
+
{ id: 'email', labelKey: 'methodEmail', availableFor: { login: true, register: true } },
|
|
6
|
+
{ id: 'google', labelKey: 'methodGoogle', availableFor: { login: true, register: true } },
|
|
7
|
+
{ id: 'linkedin', labelKey: 'methodLinkedin', availableFor: { login: true, register: true } },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
// Social methods share ONE device flow; only the `provider` query param differs.
|
|
11
|
+
const SOCIAL_METHODS = new Set(['google', 'linkedin']);
|
|
12
|
+
function isSocialMethod(id) {
|
|
13
|
+
return SOCIAL_METHODS.has(id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function methodsForFlow(flow) {
|
|
17
|
+
return AUTH_METHODS.filter((m) => m.availableFor && m.availableFor[flow] === true);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function methodIdsForFlow(flow) {
|
|
21
|
+
return methodsForFlow(flow).map((m) => m.id);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isMethodAvailable(id, flow) {
|
|
25
|
+
return methodIdsForFlow(flow).includes(id);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function methodLabel(method, catalog) {
|
|
29
|
+
const entry = typeof method === 'string' ? AUTH_METHODS.find((m) => m.id === method) : method;
|
|
30
|
+
const key = entry && entry.labelKey;
|
|
31
|
+
if (catalog && key && catalog[key]) return catalog[key];
|
|
32
|
+
return entry ? entry.id : String(method);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { AUTH_METHODS, SOCIAL_METHODS, isSocialMethod, methodsForFlow, methodIdsForFlow, isMethodAvailable, methodLabel };
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { getConfigDir } = require('./config-dir');
|
|
7
|
+
|
|
8
|
+
// `~/.config/shakers/auth-session.json` — the logged-in Talent's session (talents-ai-score, issue 122 / ADR-044).
|
|
9
|
+
|
|
10
|
+
// Hub's `UserSubtypeEnum` (shared/domain/enum/user-subtype.enum.ts), copied VERBATIM — this repo never invents a value the wire does not send.
|
|
11
|
+
const PLATFORM_TALENT_USER_TYPES = new Set(['WORKS_TALENT', 'WORKS_PARTNER']);
|
|
12
|
+
const KNOWN_USER_TYPES = new Set(['HUB', 'WORKS_CLIENT', 'WORKS_TALENT', 'WORKS_PARTNER']);
|
|
13
|
+
|
|
14
|
+
// Decodes (NEVER verifies — this module has no secret to verify with, and the claim only ever picks which COPY prints) a JWT's payload segment.
|
|
15
|
+
function decodeJwtPayload(token) {
|
|
16
|
+
if (typeof token !== 'string' || !token) return null;
|
|
17
|
+
const parts = token.split('.');
|
|
18
|
+
if (parts.length !== 3) return null;
|
|
19
|
+
try {
|
|
20
|
+
const json = Buffer.from(parts[1], 'base64url').toString('utf8');
|
|
21
|
+
const parsed = JSON.parse(json);
|
|
22
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The one place `hubAccessToken` -> `userType` happens.
|
|
29
|
+
function userTypeFromHubToken(hubAccessToken) {
|
|
30
|
+
const payload = decodeJwtPayload(hubAccessToken);
|
|
31
|
+
const userType = payload && typeof payload.userType === 'string' ? payload.userType : null;
|
|
32
|
+
return userType && KNOWN_USER_TYPES.has(userType) ? userType : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function emailFromHubToken(hubAccessToken) {
|
|
36
|
+
const payload = decodeJwtPayload(hubAccessToken);
|
|
37
|
+
const email = payload && typeof payload.email === 'string' ? payload.email : null;
|
|
38
|
+
return email || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function firstNameFromSession(session) {
|
|
42
|
+
if (!session || typeof session !== 'object') return null;
|
|
43
|
+
const token = (typeof session.hubAccessToken === 'string' && session.hubAccessToken)
|
|
44
|
+
? session.hubAccessToken
|
|
45
|
+
: (typeof session.accessToken === 'string' ? session.accessToken : null);
|
|
46
|
+
const payload = decodeJwtPayload(token);
|
|
47
|
+
if (!payload) return null;
|
|
48
|
+
const raw = [payload.name, payload.given_name, payload.firstName, payload.first_name]
|
|
49
|
+
.find((v) => typeof v === 'string' && v.trim());
|
|
50
|
+
if (!raw) return null;
|
|
51
|
+
const first = raw.trim().split(/\s+/)[0];
|
|
52
|
+
return first || null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// `exp` (JWT seconds-since-epoch) -> ISO string, or null.
|
|
56
|
+
function expiresAtFromToken(token) {
|
|
57
|
+
const payload = decodeJwtPayload(token);
|
|
58
|
+
const exp = payload && typeof payload.exp === 'number' && Number.isFinite(payload.exp) ? payload.exp : null;
|
|
59
|
+
if (exp === null) return null;
|
|
60
|
+
try {
|
|
61
|
+
return new Date(exp * 1000).toISOString();
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The ONE predicate every gate reads (src/report-gating.js), so two call
|
|
68
|
+
// sites can never disagree about which `userType` values count as a Talent.
|
|
69
|
+
function isPlatformTalent(session) {
|
|
70
|
+
return !!(session && PLATFORM_TALENT_USER_TYPES.has(session.userType));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SESSION_FILE_NAME = 'auth-session.json';
|
|
74
|
+
|
|
75
|
+
// Bumped when the shape changes in a way an older/newer CLI cannot read.
|
|
76
|
+
const SESSION_FILE_VERSION = 2;
|
|
77
|
+
|
|
78
|
+
function configDir(env = process.env) {
|
|
79
|
+
return getConfigDir(env);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function authSessionPath(env = process.env) {
|
|
83
|
+
return path.join(configDir(env), SESSION_FILE_NAME);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ATOMIC 0600 write.
|
|
87
|
+
function saveAuthSession(session, env = process.env, { fs: fsImpl = fs } = {}) {
|
|
88
|
+
const explicitHubToken = typeof session.hubAccessToken === 'string' && session.hubAccessToken ? session.hubAccessToken : null;
|
|
89
|
+
const hubAccessToken = explicitHubToken || (typeof session.accessToken === 'string' && session.accessToken ? session.accessToken : null);
|
|
90
|
+
|
|
91
|
+
const cookie = typeof session.cookie === 'string' && session.cookie ? session.cookie : null;
|
|
92
|
+
let expiresAt;
|
|
93
|
+
let accessTokenExpiresAt;
|
|
94
|
+
if (cookie) {
|
|
95
|
+
expiresAt = (typeof session.cookieExpiresAt === 'string' && session.cookieExpiresAt)
|
|
96
|
+
? session.cookieExpiresAt
|
|
97
|
+
: (typeof session.expiresAt === 'string' && session.expiresAt ? session.expiresAt : null);
|
|
98
|
+
accessTokenExpiresAt = (typeof session.accessTokenExpiresAt === 'string' && session.accessTokenExpiresAt)
|
|
99
|
+
? session.accessTokenExpiresAt
|
|
100
|
+
: expiresAtFromToken(session.accessToken);
|
|
101
|
+
} else {
|
|
102
|
+
expiresAt = (typeof session.expiresAt === 'string' && session.expiresAt) ? session.expiresAt : expiresAtFromToken(session.accessToken);
|
|
103
|
+
accessTokenExpiresAt = expiresAt;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const payload = `${JSON.stringify(
|
|
107
|
+
{
|
|
108
|
+
accessToken: typeof session.accessToken === 'string' && session.accessToken ? session.accessToken : null,
|
|
109
|
+
expiresAt: expiresAt ?? null,
|
|
110
|
+
accessTokenExpiresAt: accessTokenExpiresAt ?? null,
|
|
111
|
+
cookie,
|
|
112
|
+
email: (typeof session.email === 'string' && session.email ? session.email : null)
|
|
113
|
+
|| emailFromHubToken(hubAccessToken),
|
|
114
|
+
hubAccessToken,
|
|
115
|
+
// Decoded once from the SAME `hubAccessToken` this session carries (see the
|
|
116
|
+
// module docblock) — never a second round-trip or source of truth.
|
|
117
|
+
userType: userTypeFromHubToken(hubAccessToken),
|
|
118
|
+
version: SESSION_FILE_VERSION,
|
|
119
|
+
},
|
|
120
|
+
null,
|
|
121
|
+
2,
|
|
122
|
+
)}\n`;
|
|
123
|
+
|
|
124
|
+
const target = authSessionPath(env);
|
|
125
|
+
const dir = path.dirname(target);
|
|
126
|
+
fsImpl.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
127
|
+
|
|
128
|
+
const tmp = path.join(dir, `.${SESSION_FILE_NAME}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
129
|
+
let fd = null;
|
|
130
|
+
try {
|
|
131
|
+
fd = fsImpl.openSync(tmp, 'wx', 0o600);
|
|
132
|
+
fsImpl.writeFileSync(fd, payload);
|
|
133
|
+
try { fsImpl.fsyncSync(fd); } catch { /* e.g. EINVAL on some filesystems */ }
|
|
134
|
+
fsImpl.closeSync(fd);
|
|
135
|
+
fd = null;
|
|
136
|
+
fsImpl.renameSync(tmp, target);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
if (fd !== null) { try { fsImpl.closeSync(fd); } catch { /* already closed */ } }
|
|
139
|
+
// A stale temp would accumulate one bearer-token-holding file per failure.
|
|
140
|
+
try { fsImpl.unlinkSync(tmp); } catch { /* never existed */ }
|
|
141
|
+
throw e;
|
|
142
|
+
}
|
|
143
|
+
return target;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Reads the file, or `null` for "no session": missing, unreadable, malformed JSON, unrecognized `version`, or missing the one field without which there is no session (`accessToken`).
|
|
147
|
+
function loadAuthSession(env = process.env, { fs: fsImpl = fs } = {}) {
|
|
148
|
+
let parsed;
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(fsImpl.readFileSync(authSessionPath(env), 'utf8'));
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
155
|
+
if (parsed.version !== SESSION_FILE_VERSION) return null;
|
|
156
|
+
// A usable session needs at least ONE credential: the durable cookie (email/
|
|
157
|
+
// better-auth) or a JWT (Google/legacy, or an email session whose JWT is cached).
|
|
158
|
+
const hasCookie = typeof parsed.cookie === 'string' && parsed.cookie;
|
|
159
|
+
const hasToken = typeof parsed.accessToken === 'string' && parsed.accessToken;
|
|
160
|
+
if (!hasCookie && !hasToken) return null;
|
|
161
|
+
const hubAccessToken = typeof parsed.hubAccessToken === 'string' && parsed.hubAccessToken ? parsed.hubAccessToken : null;
|
|
162
|
+
const email = (typeof parsed.email === 'string' && parsed.email ? parsed.email : null) || emailFromHubToken(hubAccessToken);
|
|
163
|
+
return {
|
|
164
|
+
accessToken: hasToken ? parsed.accessToken : null,
|
|
165
|
+
expiresAt: typeof parsed.expiresAt === 'string' ? parsed.expiresAt : null,
|
|
166
|
+
accessTokenExpiresAt: typeof parsed.accessTokenExpiresAt === 'string' ? parsed.accessTokenExpiresAt : null,
|
|
167
|
+
cookie: hasCookie ? parsed.cookie : null,
|
|
168
|
+
email,
|
|
169
|
+
hubAccessToken,
|
|
170
|
+
userType: typeof parsed.userType === 'string' && KNOWN_USER_TYPES.has(parsed.userType) ? parsed.userType : null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Removes the file. Idempotent and never throws — `logout` on no session is a
|
|
175
|
+
// no-op, not a failure.
|
|
176
|
+
function clearAuthSession(env = process.env, { fs: fsImpl = fs } = {}) {
|
|
177
|
+
try {
|
|
178
|
+
fsImpl.unlinkSync(authSessionPath(env));
|
|
179
|
+
return true;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// `none | active | expired`, the three states the caller needs to give the Talent a true message.
|
|
186
|
+
function sessionStatus(session, now = Date.now()) {
|
|
187
|
+
// A credential is either the durable cookie (email) or a JWT (Google/legacy).
|
|
188
|
+
const hasCred = !!(session && ((typeof session.accessToken === 'string' && session.accessToken) || (typeof session.cookie === 'string' && session.cookie)));
|
|
189
|
+
if (!hasCred) return 'none';
|
|
190
|
+
const expiry = Date.parse(String(session.expiresAt || ''));
|
|
191
|
+
if (!Number.isFinite(expiry)) return 'expired';
|
|
192
|
+
return expiry > now ? 'active' : 'expired';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The single predicate the rest of the CLI asks: is there a usable session right
|
|
196
|
+
// now? Everything gating reads reduces to this.
|
|
197
|
+
function isLoggedIn(env = process.env, now = Date.now(), deps = {}) {
|
|
198
|
+
return sessionStatus(loadAuthSession(env, deps), now) === 'active';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
SESSION_FILE_NAME,
|
|
203
|
+
SESSION_FILE_VERSION,
|
|
204
|
+
authSessionPath,
|
|
205
|
+
saveAuthSession,
|
|
206
|
+
loadAuthSession,
|
|
207
|
+
clearAuthSession,
|
|
208
|
+
sessionStatus,
|
|
209
|
+
isLoggedIn,
|
|
210
|
+
isPlatformTalent,
|
|
211
|
+
PLATFORM_TALENT_USER_TYPES,
|
|
212
|
+
decodeJwtPayload,
|
|
213
|
+
firstNameFromSession,
|
|
214
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// Git AUTHORSHIP + provenance for the certify phase (skill-code-certification, ADR-017).
|
|
7
|
+
|
|
8
|
+
const GIT_TIMEOUT_MS = 15000;
|
|
9
|
+
const SHORT_SHA_LEN = 10;
|
|
10
|
+
|
|
11
|
+
function runGit(root, args) {
|
|
12
|
+
try {
|
|
13
|
+
return execFileSync('git', ['-C', root, ...args], {
|
|
14
|
+
encoding: 'utf8',
|
|
15
|
+
timeout: GIT_TIMEOUT_MS,
|
|
16
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
17
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
18
|
+
});
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// True only inside a real work tree. Everything else (no git, bare repo, not a
|
|
25
|
+
// repo) is treated as "cannot attribute" upstream.
|
|
26
|
+
function isGitRepo(root) {
|
|
27
|
+
return runGit(root, ['rev-parse', '--is-inside-work-tree']) !== null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Repo identity for the evidence record.
|
|
31
|
+
function getRepository(root) {
|
|
32
|
+
const remote = runGit(root, ['config', '--get', 'remote.origin.url']);
|
|
33
|
+
if (remote && remote.trim()) return normalizeRemote(remote.trim());
|
|
34
|
+
const top = runGit(root, ['rev-parse', '--show-toplevel']);
|
|
35
|
+
if (top && top.trim()) return path.basename(top.trim());
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeRemote(url) {
|
|
40
|
+
// `git@host:owner/repo.git` | `https://[token@]host/owner/repo.git` ->
|
|
41
|
+
// `host/owner/repo`. Any userinfo (a token/password) is dropped, never kept.
|
|
42
|
+
let s = url.replace(/\.git$/, '');
|
|
43
|
+
const scp = s.match(/^[^@]+@([^:]+):(.+)$/); // scp-like ssh
|
|
44
|
+
if (scp) return `${scp[1]}/${scp[2]}`;
|
|
45
|
+
try {
|
|
46
|
+
const u = new URL(s);
|
|
47
|
+
return `${u.host}${u.pathname}`.replace(/\/+$/, '');
|
|
48
|
+
} catch {
|
|
49
|
+
return s;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// `<root-commit>..<HEAD>` short shas over the current branch. `null` when the
|
|
54
|
+
// history can't be read (e.g. a repo with no commits yet).
|
|
55
|
+
function getCommitRange(root) {
|
|
56
|
+
const head = runGit(root, ['rev-parse', `--short=${SHORT_SHA_LEN}`, 'HEAD']);
|
|
57
|
+
if (!head || !head.trim()) return null;
|
|
58
|
+
const roots = runGit(root, ['rev-list', '--max-parents=0', 'HEAD']);
|
|
59
|
+
const firstFull = roots && roots.trim() ? roots.trim().split('\n').pop().trim() : null;
|
|
60
|
+
if (!firstFull) return head.trim();
|
|
61
|
+
const first = runGit(root, ['rev-parse', `--short=${SHORT_SHA_LEN}`, firstFull]);
|
|
62
|
+
const firstShort = first && first.trim() ? first.trim() : firstFull.slice(0, SHORT_SHA_LEN);
|
|
63
|
+
return `${firstShort}..${head.trim()}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The path git sees for a file is repo-relative; the sampler's paths are relative to `root`, which may be a SUBDIR of the repo.
|
|
67
|
+
function getRootPrefix(root) {
|
|
68
|
+
const prefix = runGit(root, ['rev-parse', '--show-prefix']);
|
|
69
|
+
return prefix && prefix.trim() ? prefix.trim().replace(/\/+$/, '') : '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildPathIndex(root) {
|
|
73
|
+
const authorsByPath = new Map();
|
|
74
|
+
const timeByPath = new Map();
|
|
75
|
+
const log = runGit(root, [
|
|
76
|
+
'log',
|
|
77
|
+
'--no-merges',
|
|
78
|
+
'--pretty=format:\x01%ae\x1f%at',
|
|
79
|
+
'--name-only',
|
|
80
|
+
]);
|
|
81
|
+
if (log === null) return { authorsByPath, timeByPath };
|
|
82
|
+
|
|
83
|
+
let currentEmail = null;
|
|
84
|
+
let currentTime = null;
|
|
85
|
+
for (const rawLine of log.split('\n')) {
|
|
86
|
+
if (rawLine.startsWith('\x01')) {
|
|
87
|
+
const [email, atRaw] = rawLine.slice(1).split('\x1f');
|
|
88
|
+
currentEmail = String(email || '').trim().toLowerCase();
|
|
89
|
+
const ts = Number.parseInt(atRaw, 10);
|
|
90
|
+
currentTime = Number.isFinite(ts) ? ts : null;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const file = rawLine.trim();
|
|
94
|
+
if (!file || currentEmail === null) continue;
|
|
95
|
+
let set = authorsByPath.get(file);
|
|
96
|
+
if (!set) {
|
|
97
|
+
set = new Set();
|
|
98
|
+
authorsByPath.set(file, set);
|
|
99
|
+
}
|
|
100
|
+
set.add(currentEmail);
|
|
101
|
+
if (currentTime !== null) {
|
|
102
|
+
const prev = timeByPath.get(file);
|
|
103
|
+
if (prev === undefined || currentTime > prev) {
|
|
104
|
+
timeByPath.set(file, currentTime);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { authorsByPath, timeByPath };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Full authorship context for `root`, collected ONCE per run.
|
|
112
|
+
function collectAuthorship(root) {
|
|
113
|
+
if (!isGitRepo(root)) {
|
|
114
|
+
return {
|
|
115
|
+
available: false,
|
|
116
|
+
repository: null,
|
|
117
|
+
commitRange: null,
|
|
118
|
+
authorsForPath: () => [],
|
|
119
|
+
mostRecentCommitTime: () => null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const prefix = getRootPrefix(root);
|
|
124
|
+
const { authorsByPath, timeByPath } = buildPathIndex(root);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
available: true,
|
|
128
|
+
repository: getRepository(root),
|
|
129
|
+
commitRange: getCommitRange(root),
|
|
130
|
+
authorsForPath(relPathFromRoot) {
|
|
131
|
+
if (typeof relPathFromRoot !== 'string' || !relPathFromRoot) return [];
|
|
132
|
+
const repoRel = prefix ? `${prefix}/${relPathFromRoot}` : relPathFromRoot;
|
|
133
|
+
const set = authorsByPath.get(repoRel);
|
|
134
|
+
return set ? [...set] : [];
|
|
135
|
+
},
|
|
136
|
+
// UNIX seconds of the most recent commit touching `relPathFromRoot`, or `null` when the path is untracked / history is unreadable.
|
|
137
|
+
mostRecentCommitTime(relPathFromRoot) {
|
|
138
|
+
if (typeof relPathFromRoot !== 'string' || !relPathFromRoot) return null;
|
|
139
|
+
const repoRel = prefix ? `${prefix}/${relPathFromRoot}` : relPathFromRoot;
|
|
140
|
+
const t = timeByPath.get(repoRel);
|
|
141
|
+
return t === undefined ? null : t;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Builds an "is this author attributable?" predicate for one certifying identity.
|
|
147
|
+
function buildAttributionPredicate(verifiedEmail, authorizedSet) {
|
|
148
|
+
const target = String(verifiedEmail || '').trim().toLowerCase();
|
|
149
|
+
const domainSuffix =
|
|
150
|
+
authorizedSet && authorizedSet.domain
|
|
151
|
+
? `@${String(authorizedSet.domain).trim().toLowerCase()}`
|
|
152
|
+
: null;
|
|
153
|
+
const extra = new Set(
|
|
154
|
+
authorizedSet && Array.isArray(authorizedSet.extraEmails)
|
|
155
|
+
? authorizedSet.extraEmails.map((e) => String(e).trim().toLowerCase())
|
|
156
|
+
: [],
|
|
157
|
+
);
|
|
158
|
+
return (author) => {
|
|
159
|
+
const a = String(author || '').toLowerCase();
|
|
160
|
+
if (target && a === target) return true;
|
|
161
|
+
if (domainSuffix && a.endsWith(domainSuffix)) return true;
|
|
162
|
+
if (extra.has(a)) return true;
|
|
163
|
+
return false;
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Applies the ADR-017 gate (with the ADR-023 widening for test identities) to ONE sampled Skill.
|
|
168
|
+
function attributeSample(sample, verifiedEmail, authorship, authorizedSet = null) {
|
|
169
|
+
const isAttributable = buildAttributionPredicate(verifiedEmail, authorizedSet);
|
|
170
|
+
const files = Array.isArray(sample.files) ? sample.files : [];
|
|
171
|
+
|
|
172
|
+
const consideredEmails = new Set();
|
|
173
|
+
const attributableFiles = [];
|
|
174
|
+
const fileAttribution = [];
|
|
175
|
+
|
|
176
|
+
for (const file of files) {
|
|
177
|
+
const authors = authorship.authorsForPath(file.path);
|
|
178
|
+
for (const a of authors) consideredEmails.add(a);
|
|
179
|
+
const attributed = authors.some((a) => isAttributable(a));
|
|
180
|
+
if (attributed) attributableFiles.push(file);
|
|
181
|
+
fileAttribution.push({ path: file.path, authors, attributed });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const authorEmails = [...consideredEmails].map((email) => ({
|
|
185
|
+
email,
|
|
186
|
+
matched: isAttributable(email),
|
|
187
|
+
}));
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
attributableFiles,
|
|
191
|
+
authorEmails,
|
|
192
|
+
fileAttribution,
|
|
193
|
+
certifiable: attributableFiles.length > 0,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = {
|
|
198
|
+
collectAuthorship,
|
|
199
|
+
attributeSample,
|
|
200
|
+
// exported for unit tests
|
|
201
|
+
normalizeRemote,
|
|
202
|
+
isGitRepo,
|
|
203
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const { getHomeDir } = require('./env-paths');
|
|
7
|
+
|
|
8
|
+
// Deterministic (no-LLM) automations detector (talents-ai-score, issue 017 / ADR-013-014).
|
|
9
|
+
|
|
10
|
+
const AI_CLI_NAMES = ['claude', 'aider', 'gemini', 'codex'];
|
|
11
|
+
const AI_CLI_RE = new RegExp(`\\b(${AI_CLI_NAMES.join('|')})\\b`, 'i');
|
|
12
|
+
|
|
13
|
+
function mentionsAiCli(text) {
|
|
14
|
+
return typeof text === 'string' && AI_CLI_RE.test(text);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function looksLikeJsonPiping(text) {
|
|
18
|
+
if (typeof text !== 'string') return false;
|
|
19
|
+
return mentionsAiCli(text) && /\|/.test(text) && /(--json\b|(^|\s)-p\b)/.test(text);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MAX_SHELL_SCRIPT_BYTES = 200_000; // safety cap, never a product requirement
|
|
23
|
+
const MAX_SHELL_SCRIPTS = 50;
|
|
24
|
+
|
|
25
|
+
/* ---------- 1 & 2: scripts (npm + shell) ---------- */
|
|
26
|
+
|
|
27
|
+
function scanNpmScripts(root) {
|
|
28
|
+
let raw;
|
|
29
|
+
try {
|
|
30
|
+
raw = fs.readFileSync(path.join(root, 'package.json'), 'utf8');
|
|
31
|
+
} catch {
|
|
32
|
+
return { aiMentions: 0, jsonPiping: 0 };
|
|
33
|
+
}
|
|
34
|
+
let pkg;
|
|
35
|
+
try {
|
|
36
|
+
pkg = JSON.parse(raw);
|
|
37
|
+
} catch {
|
|
38
|
+
return { aiMentions: 0, jsonPiping: 0 };
|
|
39
|
+
}
|
|
40
|
+
const scripts = pkg && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
41
|
+
let aiMentions = 0;
|
|
42
|
+
let jsonPiping = 0;
|
|
43
|
+
for (const command of Object.values(scripts)) {
|
|
44
|
+
if (mentionsAiCli(command)) aiMentions += 1;
|
|
45
|
+
if (looksLikeJsonPiping(command)) jsonPiping += 1;
|
|
46
|
+
}
|
|
47
|
+
return { aiMentions, jsonPiping };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function scanShellScripts(root) {
|
|
51
|
+
const dir = path.join(root, 'scripts');
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
55
|
+
} catch {
|
|
56
|
+
return { aiMentions: 0, jsonPiping: 0 };
|
|
57
|
+
}
|
|
58
|
+
let aiMentions = 0;
|
|
59
|
+
let jsonPiping = 0;
|
|
60
|
+
let scanned = 0;
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (scanned >= MAX_SHELL_SCRIPTS) break;
|
|
63
|
+
if (!entry.isFile() || !entry.name.endsWith('.sh')) continue;
|
|
64
|
+
scanned += 1;
|
|
65
|
+
const file = path.join(dir, entry.name);
|
|
66
|
+
let content;
|
|
67
|
+
try {
|
|
68
|
+
const st = fs.statSync(file);
|
|
69
|
+
if (st.size > MAX_SHELL_SCRIPT_BYTES) continue;
|
|
70
|
+
content = fs.readFileSync(file, 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (mentionsAiCli(content)) aiMentions += 1;
|
|
75
|
+
if (looksLikeJsonPiping(content)) jsonPiping += 1;
|
|
76
|
+
}
|
|
77
|
+
return { aiMentions, jsonPiping };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ---------- 3: schedulers (inspected-where-possible) ---------- */
|
|
81
|
+
|
|
82
|
+
// cron: the CURRENT user's own crontab only (`crontab -l`), never the system-wide crontab (would need elevated permissions, not attempted).
|
|
83
|
+
function probeCron() {
|
|
84
|
+
let out;
|
|
85
|
+
try {
|
|
86
|
+
out = execFileSync('crontab', ['-l'], { timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).toString('utf8');
|
|
87
|
+
} catch {
|
|
88
|
+
return { inspected: false, matches: 0 }; // no crontab / binary missing / not permitted: not invented
|
|
89
|
+
}
|
|
90
|
+
const lines = out.split('\n').filter((l) => l.trim() && !l.trim().startsWith('#'));
|
|
91
|
+
return { inspected: true, matches: lines.filter(mentionsAiCli).length };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// launchd (macOS): the user's own ~/Library/LaunchAgents only (never LaunchDaemons, system-wide, needs root).
|
|
95
|
+
function probeLaunchd(home) {
|
|
96
|
+
const dir = path.join(home, 'Library', 'LaunchAgents');
|
|
97
|
+
let entries;
|
|
98
|
+
try {
|
|
99
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
100
|
+
} catch {
|
|
101
|
+
return { inspected: false, matches: 0 };
|
|
102
|
+
}
|
|
103
|
+
let matches = 0;
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (!entry.isFile() || !entry.name.endsWith('.plist')) continue;
|
|
106
|
+
try {
|
|
107
|
+
const content = fs.readFileSync(path.join(dir, entry.name), 'utf8');
|
|
108
|
+
if (mentionsAiCli(content)) matches += 1;
|
|
109
|
+
} catch {
|
|
110
|
+
/* unreadable: skip, never invented */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { inspected: true, matches };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// pm2: the user's own process dump (~/.pm2/dump.pm2, JSON). Counts AI-CLI
|
|
117
|
+
// mentions across the whole dump — coarse but derived, never returns it.
|
|
118
|
+
function probePm2(home) {
|
|
119
|
+
let raw;
|
|
120
|
+
try {
|
|
121
|
+
raw = fs.readFileSync(path.join(home, '.pm2', 'dump.pm2'), 'utf8');
|
|
122
|
+
} catch {
|
|
123
|
+
return { inspected: false, matches: 0 };
|
|
124
|
+
}
|
|
125
|
+
const matches = (raw.match(new RegExp(AI_CLI_RE.source, 'gi')) || []).length;
|
|
126
|
+
return { inspected: true, matches };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// systemd (Linux, user-level only): ~/.config/systemd/user/*.service.
|
|
130
|
+
// Never the system-wide /etc/systemd (needs root, not attempted).
|
|
131
|
+
function probeSystemdUser(home) {
|
|
132
|
+
const dir = path.join(home, '.config', 'systemd', 'user');
|
|
133
|
+
let entries;
|
|
134
|
+
try {
|
|
135
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
136
|
+
} catch {
|
|
137
|
+
return { inspected: false, matches: 0 };
|
|
138
|
+
}
|
|
139
|
+
let matches = 0;
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (!entry.isFile() || !entry.name.endsWith('.service')) continue;
|
|
142
|
+
try {
|
|
143
|
+
const content = fs.readFileSync(path.join(dir, entry.name), 'utf8');
|
|
144
|
+
if (mentionsAiCli(content)) matches += 1;
|
|
145
|
+
} catch {
|
|
146
|
+
/* unreadable: skip, never invented */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { inspected: true, matches };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Deterministic (no-LLM) automations detection.
|
|
153
|
+
function detectAutomations(root) {
|
|
154
|
+
const home = getHomeDir();
|
|
155
|
+
const npm = scanNpmScripts(root);
|
|
156
|
+
const shell = scanShellScripts(root);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
scripts: { npm: npm.aiMentions, shell: shell.aiMentions },
|
|
160
|
+
jsonPiping: npm.jsonPiping + shell.jsonPiping,
|
|
161
|
+
schedulers: {
|
|
162
|
+
cron: probeCron(),
|
|
163
|
+
launchd: probeLaunchd(home),
|
|
164
|
+
pm2: probePm2(home),
|
|
165
|
+
systemd: probeSystemdUser(home),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = { detectAutomations, mentionsAiCli, looksLikeJsonPiping };
|