@ai-adoption-score/ai-coding-usage 1.0.0 → 1.0.1
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 +33 -3
- package/package.json +1 -1
- package/src/api.mjs +18 -0
- package/src/choice.mjs +22 -2
- package/src/cli.mjs +8 -9
- package/src/scan.mjs +13 -4
- package/src/sync.mjs +3 -3
package/README.md
CHANGED
|
@@ -11,15 +11,45 @@ Connected once, it shares by itself: a hook in Claude Code and Codex syncs after
|
|
|
11
11
|
minutes and always when a session ends, and an automatic ping-back, once an hour and without any numbers, tells the
|
|
12
12
|
report "no AI use today" from "no signal".
|
|
13
13
|
|
|
14
|
+
## How it works, step by step
|
|
15
|
+
|
|
16
|
+
In short: connect, choose on the survey page, then share. Before the choice, only repository names leave the Mac.
|
|
17
|
+
|
|
18
|
+
1. **The person pastes the line from their survey page** into Claude Code, Codex or Terminal. The code in it works
|
|
19
|
+
once, for 30 minutes; the server trades it for a token for this Mac.
|
|
20
|
+
2. **The add-on puts its hooks and the ping-back in place** and copies itself to `~/.ai-adoption-score`. Why: the
|
|
21
|
+
survey gets the numbers after every session without anyone running a command again, and a hook can't run from
|
|
22
|
+
npx's temporary folder. The ping-back carries no numbers; it lets the report tell "no AI use today" from "no signal".
|
|
23
|
+
3. **It sends the names of the repositories used in the last 30 days, and nothing else.** Why: the person chooses on
|
|
24
|
+
their survey page, and the page needs the names to list them. The choice isn't asked in the terminal, because the
|
|
25
|
+
line usually runs inside Claude Code or Codex, where the add-on can't ask questions. The server keeps the list for
|
|
26
|
+
an hour at most.
|
|
27
|
+
4. **The person ticks what to share** on their survey page; everything is ticked to start with. When they press
|
|
28
|
+
Share, the list is deleted, and with it the names they didn't tick.
|
|
29
|
+
5. **Only then do numbers leave the Mac:** the last 30 days of what they ticked, then the new numbers after each
|
|
30
|
+
session. Before every sync the add-on asks what they chose and keeps everything else on the Mac; the server checks
|
|
31
|
+
the same rule again.
|
|
32
|
+
6. **It removes itself** when the Mac is disconnected on the survey page or the survey closes. `disconnect` does the
|
|
33
|
+
same from the Mac.
|
|
34
|
+
|
|
35
|
+
The person makes their choice on the survey page, so the survey's server keeps it and the add-on follows the server's
|
|
36
|
+
answer. It still checks what it's told:
|
|
37
|
+
|
|
38
|
+
- It reads 30 days back at most, whatever the server asks for.
|
|
39
|
+
- It shares nothing when the choice isn't one of the two kinds the server keeps: what the person ticked, or
|
|
40
|
+
`{ "all": true }` for links made before people could choose.
|
|
41
|
+
- It sends the token over https only. Plain http works only for this Mac's own address (`localhost`), for testing.
|
|
42
|
+
|
|
14
43
|
## Commands
|
|
15
44
|
|
|
16
45
|
- `connect <code> [--server <url>]`: links this Mac to the survey with the code from the survey page and puts the
|
|
17
46
|
hooks and the ping-back in place. Then it sends the names of the repositories used in the last 30 days, and nothing
|
|
18
47
|
else, so the person can choose on their survey page which to share; a background wait shares the last 30 days of
|
|
19
48
|
those once they press Share. The server defaults to `https://app.ai-adoption-score.app`;
|
|
20
|
-
`AI_ADOPTION_SCORE_SERVER` or `--server http://localhost:3000` point it at a local app
|
|
21
|
-
connected, it keeps that Mac's link on the same server (the survey page still
|
|
22
|
-
choose again; a link on another server is disconnected, and its token never goes
|
|
49
|
+
`AI_ADOPTION_SCORE_SERVER` or `--server http://localhost:3000` point it at a local app; any other server has to be
|
|
50
|
+
https. Run again on a Mac that is connected, it keeps that Mac's link on the same server (the survey page still
|
|
51
|
+
shows one Mac) and lets the person choose again; a link on another server is disconnected, and its token never goes
|
|
52
|
+
to the new one.
|
|
23
53
|
- `sync`: shares now instead of after the next session. It counts the window again and sends the rows that changed
|
|
24
54
|
since the server last accepted them; the server replaces what it had, so nothing counts twice.
|
|
25
55
|
- `status`: which survey this Mac shares with, what the person chose, the hooks and when they last ran, and when it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-adoption-score/ai-coding-usage",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Shares how you use Claude Code and Codex with your company's AI Adoption Score survey. Never prompts, AI answers, code or file names.",
|
|
5
5
|
"homepage": "https://ai-adoption-score.app/product/ai-coding-usage",
|
|
6
6
|
"repository": {
|
package/src/api.mjs
CHANGED
|
@@ -7,6 +7,22 @@ const TIMEOUT_MS = 20_000
|
|
|
7
7
|
|
|
8
8
|
export const CLIENT_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
9
9
|
|
|
10
|
+
/** Plain http goes only to this Mac itself, for testing; the device token goes anywhere else over https. */
|
|
11
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]'])
|
|
12
|
+
|
|
13
|
+
/** Why the add-on won't talk to `server`, or null when it will. @param {string} server */
|
|
14
|
+
export function serverProblem(server) {
|
|
15
|
+
let url
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(server)
|
|
18
|
+
} catch {
|
|
19
|
+
return `"${server}" isn't a web address.`
|
|
20
|
+
}
|
|
21
|
+
if (url.protocol === 'https:') return null
|
|
22
|
+
if (url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname)) return null
|
|
23
|
+
return `"${server}" isn't https. Plain http works only for this Mac's own address (localhost), for testing.`
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
/**
|
|
11
27
|
* @param {string} server @param {string} path @param {unknown} body @param {string} [token]
|
|
12
28
|
* @returns {Promise<{ status: number, body: any, retryAfterSeconds: number | null }>}
|
|
@@ -25,6 +41,8 @@ export async function get(server, path, token) {
|
|
|
25
41
|
|
|
26
42
|
/** @param {string} server @param {string} path @param {'GET' | 'POST'} method @param {unknown} body @param {string} [token] */
|
|
27
43
|
async function request(server, path, method, body, token) {
|
|
44
|
+
const problem = serverProblem(server)
|
|
45
|
+
if (problem) throw new Error(problem)
|
|
28
46
|
const controller = new AbortController()
|
|
29
47
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
|
30
48
|
try {
|
package/src/choice.mjs
CHANGED
|
@@ -19,13 +19,33 @@ export function repositoryOwner(repository) {
|
|
|
19
19
|
return parts.length >= 3 ? `${parts[0]}/${parts[1]}`.toLowerCase() : null
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The choice in the server's answer, taken only in one of the two shapes the server keeps: what the person ticked, or
|
|
24
|
+
* `{ all: true }` for links made before people could choose. Anything else shares nothing.
|
|
25
|
+
* @param {unknown} value @returns {RepositoryChoice | null}
|
|
26
|
+
*/
|
|
27
|
+
export function readChoice(value) {
|
|
28
|
+
if (!value || typeof value !== 'object') return null
|
|
29
|
+
const choice = /** @type {Record<string, unknown>} */ (value)
|
|
30
|
+
if ('all' in choice) return choice.all === true ? { all: true } : null
|
|
31
|
+
const names = (list) => Array.isArray(list) && list.every((name) => typeof name === 'string')
|
|
32
|
+
if (!names(choice.repositories) || !names(choice.owners)) return null
|
|
33
|
+
if (typeof choice.outsideGit !== 'boolean' || typeof choice.localFolders !== 'boolean') return null
|
|
34
|
+
return {
|
|
35
|
+
repositories: /** @type {string[]} */ (choice.repositories),
|
|
36
|
+
owners: /** @type {string[]} */ (choice.owners),
|
|
37
|
+
outsideGit: choice.outsideGit,
|
|
38
|
+
localFolders: choice.localFolders,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
/**
|
|
23
43
|
* Whether a session in `repository` (null outside any repository) is shared. Nothing is, before the person chooses.
|
|
24
44
|
* @param {RepositoryChoice | null} choice @param {string | null} repository
|
|
25
45
|
*/
|
|
26
46
|
export function isShared(choice, repository) {
|
|
27
47
|
if (!choice) return false
|
|
28
|
-
if ('all' in choice) return true
|
|
48
|
+
if ('all' in choice) return choice.all === true
|
|
29
49
|
if (!repository) return choice.outsideGit
|
|
30
50
|
if (choice.repositories.includes(repository)) return true
|
|
31
51
|
if (repository.startsWith(LOCAL_PREFIX)) return choice.localFolders
|
|
@@ -64,7 +84,7 @@ export function repositoryList(rows) {
|
|
|
64
84
|
/** What this Mac shares, in a line for `status`. @param {RepositoryChoice | null} choice */
|
|
65
85
|
export function describeChoice(choice) {
|
|
66
86
|
if (!choice) return 'nothing yet: choose your repositories on your survey page'
|
|
67
|
-
if ('all' in choice) return 'every repository'
|
|
87
|
+
if ('all' in choice) return choice.all === true ? 'every repository' : 'nothing'
|
|
68
88
|
const parts = [`${choice.repositories.length} ${choice.repositories.length === 1 ? 'repository' : 'repositories'}`]
|
|
69
89
|
if (choice.owners.length > 0) parts.push(`new ones from ${choice.owners.join(', ')}`)
|
|
70
90
|
if (choice.outsideGit) parts.push('work outside a repository')
|
package/src/cli.mjs
CHANGED
|
@@ -4,13 +4,13 @@ import { existsSync, rmSync } from 'node:fs'
|
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
|
-
import { CLIENT_VERSION, post } from './api.mjs'
|
|
7
|
+
import { CLIENT_VERSION, post, serverProblem } from './api.mjs'
|
|
8
8
|
import { runCheckIn } from './checkin.mjs'
|
|
9
9
|
import { DEFAULT_SERVER, readConfig, writeConfig } from './config.mjs'
|
|
10
10
|
import { runHook } from './hook.mjs'
|
|
11
11
|
import { hookState, installAll, uninstallAll } from './install.mjs'
|
|
12
12
|
import { paths } from './paths.mjs'
|
|
13
|
-
import { scanUsage } from './scan.mjs'
|
|
13
|
+
import { daysToRead, scanUsage } from './scan.mjs'
|
|
14
14
|
import { readState } from './state.mjs'
|
|
15
15
|
import { describeChoice, repositoryList } from './choice.mjs'
|
|
16
16
|
import { formatSummary } from './summary.mjs'
|
|
@@ -109,11 +109,8 @@ async function connect(args) {
|
|
|
109
109
|
else return fail(`Unknown option "${args[index]}".`)
|
|
110
110
|
}
|
|
111
111
|
if (!code) return fail('Add the code from your survey page: npx @ai-adoption-score/ai-coding-usage@1 connect K7Q2-9M4X')
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
} catch {
|
|
115
|
-
return fail(`"${server}" isn't a web address.`)
|
|
116
|
-
}
|
|
112
|
+
const problem = serverProblem(server)
|
|
113
|
+
if (problem) return fail(problem)
|
|
117
114
|
// Run again on a Mac that is connected: the same server carries the link on with the new token, so it doesn't
|
|
118
115
|
// count a second Mac. A token never goes to another server; that survey is told the Mac left once this one connects.
|
|
119
116
|
const previous = readConfig()
|
|
@@ -131,7 +128,9 @@ async function connect(args) {
|
|
|
131
128
|
if (response.status !== 200 || response.body?.status !== 'connected') {
|
|
132
129
|
return fail(response.body?.error ?? `Couldn't connect: the server answered ${response.status}.`)
|
|
133
130
|
}
|
|
134
|
-
const { token, companyName, syncIntervalSeconds
|
|
131
|
+
const { token, companyName, syncIntervalSeconds } = response.body
|
|
132
|
+
// However far back the survey asks for, the add-on reads 30 days at most.
|
|
133
|
+
const historyDays = daysToRead(response.body.historyDays)
|
|
135
134
|
// A new connection starts with nothing sent.
|
|
136
135
|
rmSync(paths().state, { force: true })
|
|
137
136
|
const config = { server, token, companyName, connectedAt: new Date().toISOString(), syncIntervalSeconds, historyDays }
|
|
@@ -149,7 +148,7 @@ async function connect(args) {
|
|
|
149
148
|
say(` Codex: ${HOOK_WORDS[installed.codex] ?? installed.codex}`)
|
|
150
149
|
say(` Automatic ping-back: ${installed.checkIn === 'added' ? 'added, so the survey knows this Mac is still connected (no numbers)' : "couldn't add it"}`)
|
|
151
150
|
// The person chooses which repositories to share before any session leaves this Mac: only the names go now.
|
|
152
|
-
const scan = await scanUsage({ days: historyDays
|
|
151
|
+
const scan = await scanUsage({ days: historyDays })
|
|
153
152
|
const list = repositoryList(scan.rows)
|
|
154
153
|
const listed = await post(server, '/api/usage/repositories', list, token)
|
|
155
154
|
if (listed.body?.status !== 'choosing') {
|
package/src/scan.mjs
CHANGED
|
@@ -5,13 +5,22 @@ import { loadDesktopConnectorNames } from './connectors.mjs'
|
|
|
5
5
|
import { assignCommits, createGitInfo } from './git.mjs'
|
|
6
6
|
import { createRowSet, finishRow, hasUse, windowStart } from './rows.mjs'
|
|
7
7
|
|
|
8
|
+
/** The add-on never reads further back than this, whatever the server asks for. */
|
|
9
|
+
export const MAX_HISTORY_DAYS = 30
|
|
10
|
+
|
|
11
|
+
/** How many days to read: a whole number from 1 to 30, and 30 when `days` isn't one. @param {unknown} days */
|
|
12
|
+
export function daysToRead(days) {
|
|
13
|
+
const whole = Math.trunc(Number(days))
|
|
14
|
+
return Number.isFinite(whole) && whole >= 1 ? Math.min(whole, MAX_HISTORY_DAYS) : MAX_HISTORY_DAYS
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
/**
|
|
9
|
-
* Reads this Mac's Claude Code and Codex records for the last `days` calendar days and returns the rows
|
|
10
|
-
* sent, with how much of the records could be read. Nothing is written and nothing leaves the Mac.
|
|
18
|
+
* Reads this Mac's Claude Code and Codex records for the last `days` calendar days, 30 at most, and returns the rows
|
|
19
|
+
* that would be sent, with how much of the records could be read. Nothing is written and nothing leaves the Mac.
|
|
11
20
|
* @param {{ days?: number, now?: number, env?: NodeJS.ProcessEnv }} [options]
|
|
12
21
|
*/
|
|
13
|
-
export async function scanUsage({ days =
|
|
14
|
-
const since = windowStart(now, days)
|
|
22
|
+
export async function scanUsage({ days = MAX_HISTORY_DAYS, now = Date.now(), env = process.env } = {}) {
|
|
23
|
+
const since = windowStart(now, daysToRead(days))
|
|
15
24
|
const rows = createRowSet()
|
|
16
25
|
const gitInfo = createGitInfo()
|
|
17
26
|
const claudeHealth = { files: 0, lines: 0, unreadable: 0 }
|
package/src/sync.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { closeSync, openSync, readFileSync, rmSync, writeSync } from 'node:fs'
|
|
|
4
4
|
import { constants, setPriority } from 'node:os'
|
|
5
5
|
import { get, post, CLIENT_VERSION } from './api.mjs'
|
|
6
6
|
import { reportedHookState } from './checkin.mjs'
|
|
7
|
-
import { isShared } from './choice.mjs'
|
|
7
|
+
import { isShared, readChoice } from './choice.mjs'
|
|
8
8
|
import { isCurrentConfig, writeConfig } from './config.mjs'
|
|
9
9
|
import { hookState, uninstallAll } from './install.mjs'
|
|
10
10
|
import { log } from './log.mjs'
|
|
@@ -96,7 +96,7 @@ export async function askChoice(config) {
|
|
|
96
96
|
if (response.status === 429) return { status: 'waiting', choice: null, retryAfterSeconds: response.retryAfterSeconds }
|
|
97
97
|
const status = response.body?.status ?? (response.status === 401 ? 'revoked' : 'error')
|
|
98
98
|
if (status === 'error') throw new Error(response.body?.error ?? `The server answered ${response.status}.`)
|
|
99
|
-
return { status, choice: response.body?.choice
|
|
99
|
+
return { status, choice: readChoice(response.body?.choice), retryAfterSeconds: null }
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/**
|
|
@@ -136,7 +136,7 @@ export async function syncNow(config, { background = false, reason = 'manual', e
|
|
|
136
136
|
}
|
|
137
137
|
const { choice } = asked
|
|
138
138
|
|
|
139
|
-
const scan = await scanUsage({ days: config.historyDays
|
|
139
|
+
const scan = await scanUsage({ days: config.historyDays, env })
|
|
140
140
|
const included = scan.rows.filter((row) => sendsTool(row.tool, config, state, env) && isShared(choice, row.repository))
|
|
141
141
|
const changed = included.filter((row) => state.sent[rowKey(row)] !== rowHash(row))
|
|
142
142
|
const checkIn = {
|