@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 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. Run again on a Mac that is
21
- connected, it keeps that Mac's link on the same server (the survey page still shows one Mac) and lets the person
22
- choose again; a link on another server is disconnected, and its token never goes to the new one.
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.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
- try {
113
- new URL(server)
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, historyDays } = response.body
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 || 30 })
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 that would be
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 = 30, now = Date.now(), env = process.env } = {}) {
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 ?? null, retryAfterSeconds: null }
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 || 30, env })
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 = {