@ctrl-spc/cs 0.7.0 → 0.7.2

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.
@@ -1,83 +1,70 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * ═══ AGENT PANEL v3: the `cs3` bin. ═══
3
+ * ═══ AGENT PANEL v3: the panel's commands, and the harness bin that runs them. ═══
4
4
  *
5
5
  * THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may
6
- * import it.
6
+ * import it, but for the one mount `cli-v2/src/index.ts` makes on
7
+ * `panelCommand`, named in `test/panel3-isolation.contract.test.mjs`.
7
8
  *
8
- * A SEPARATE BIN RATHER THAN A SUBCOMMAND OF `cs`, deliberately: v3 never
9
- * touches v1's command router, so retiring either generation is deleting a
10
- * directory and a line of `bin`, not unpicking a switch statement two
11
- * generations share.
9
+ * ═══ THE PERSON TYPES `cs`, AND ONLY `cs`. ═══ Lane's ruling (2026-08-24):
10
+ * nothing the product names may carry a version the person never knew about,
11
+ * and there is one CLI to launch and sign into. So `say`, `answer` and `show`
12
+ * are `cs` subcommands, reached through `panelCommand` below, and the `cs3` bin
13
+ * that used to publish them is gone. The router stays HERE rather than moving
14
+ * into `index.ts` so that v3 is still one directory to delete: `index.ts` holds
15
+ * three cases and a single import, not the panel's argument handling.
12
16
  *
13
- * ═══ AND SINCE recovery-1 SLICE 4, `run` IS NOT SOMETHING A PERSON TYPES. ═══
14
- * Lane's ruling (2026-08-21): a person launches one CLI, `cs start`, and signs
15
- * into it once. `cs start` now runs the panel's poll loop through `startPanel`,
16
- * so that is the only launch the product ever names, and `cs3 run` is off the
17
- * help text above.
18
- *
19
- * ═══ IT IS STILL HERE, AND IT IS NOT A SECOND OWNER OF THE LOOP. ═══ There is
20
- * one `run()` in `run.ts` with two callers. This one signs in from
21
- * `CTRL_SPC_V3_EMAIL` / `CTRL_SPC_V3_PASSWORD`, which is what lets
22
- * `test/panel3-acceptance.mjs` and the three walk scripts stand a daemon up
23
- * against their own isolated account and their own working copy without touching
24
- * the installed CLI's `session.json` or its presence. Deleting it would mean
25
- * rebuilding that isolation inside `cs start`, which is a launch path the tests
26
- * would then own. DO NOT PUT IT BACK IN THE HELP TEXT.
17
+ * ═══ AND `run` IS STILL NOT SOMETHING A PERSON TYPES. ═══ `cs start` runs the
18
+ * panel's poll loop through `startPanel`, so that is the only launch the product
19
+ * names. `run` is reachable only by running this file directly, which is what
20
+ * `test/panel3-acceptance.mjs` and the walk scripts do. It signs in from
21
+ * `CTRL_SPC_V3_EMAIL` / `CTRL_SPC_V3_PASSWORD`, which is what lets them stand a
22
+ * daemon up against their own isolated account and their own working copy
23
+ * without touching the installed CLI's `session.json` or its presence. There is
24
+ * one `run()` in `run.ts` with two callers, not two copies of the loop. DO NOT
25
+ * PUT IT IN `cs`.
27
26
  *
28
27
  * ═══ A FAILURE EXITS NON-ZERO AND SAYS WHY, ALWAYS. ═══ Every read in `show`
29
- * and every write in `say` either returns its rows or throws, and this is the
30
- * only handler. Nothing here converts a failure into an empty result, because a
31
- * command whose whole job is observation is worthless the first time silence can
32
- * mean either "nothing is there" or "the read failed", and a `say` that exited
33
- * zero without writing would be an acknowledgement of nothing.
28
+ * and every write in `say` either returns its rows or throws, and the two
29
+ * handlers below are the only ones. Nothing here converts a failure into an
30
+ * empty result, because a command whose whole job is observation is worthless
31
+ * the first time silence can mean either "nothing is there" or "the read
32
+ * failed", and a `say` that exited zero without writing would be an
33
+ * acknowledgement of nothing.
34
34
  */
35
+ import { realpathSync } from 'node:fs';
36
+ import { fileURLToPath } from 'node:url';
35
37
  import { answer } from './answer.js';
36
38
  import { run } from './run.js';
37
39
  import { say } from './say.js';
38
40
  import { show } from './show.js';
39
- const HELP = `cs3 — CTRL+SPC agent panel v3
40
-
41
- cs3 say "<text>" Start a card and put your message on it
42
- cs3 say --project <p> "<text>" Start it filed under that project (id or name)
43
- cs3 say --card <id> "<text>" Add a message to a card you already have
44
- cs3 answer <id> "<text>" Answer a question a card is waiting on you for
45
- cs3 show Every card
46
- cs3 show <id> One card in full, or one run with its brief and report
47
- cs3 help Show this help
48
-
49
- This machine answers cards when \`cs start\` is running. These commands are the
50
- terminal's view of the same record.
51
-
52
- Acts as CTRL_SPC_V3_EMAIL / CTRL_SPC_V3_PASSWORD, against the hosted project
53
- by default. Point it at a local stack with:
54
- CTRL_SPC_SUPABASE_URL=http://127.0.0.1:54321
55
- CTRL_SPC_SUPABASE_KEY=<the local publishable key from \`supabase status\`>
56
- `;
57
- async function main() {
58
- const command = process.argv[2];
41
+ /**
42
+ * One panel command, given the whole tail of `process.argv` from the command
43
+ * word on. `cs` routes `say`, `answer` and `show` here; running this file
44
+ * directly reaches `run` as well.
45
+ */
46
+ export async function panelCommand(argv) {
47
+ const command = argv[0];
59
48
  switch (command) {
60
49
  case 'say':
61
- return say(process.argv.slice(3));
50
+ return say(argv.slice(1));
62
51
  case 'answer':
63
- return answer(process.argv.slice(3));
52
+ return answer(argv.slice(1));
64
53
  case 'run':
65
- return run(process.argv.slice(3));
54
+ return run(argv.slice(1));
66
55
  case 'show':
67
- return show(process.argv[3]);
68
- case undefined:
69
- case 'help':
70
- case '--help':
71
- case '-h':
72
- console.log(HELP);
73
- return;
56
+ return show(argv[1]);
74
57
  default:
75
- console.error(`Unknown command: ${command}\n`);
76
- console.log(HELP);
77
- process.exitCode = 1;
58
+ throw new Error(`unknown command: ${command ?? '(none)'}`);
78
59
  }
79
60
  }
80
- main().catch((err) => {
81
- console.error(`cs3: ${err instanceof Error ? err.message : String(err)}`);
82
- process.exit(1);
83
- });
61
+ /* THE HARNESS ENTRY, AND IT MUST NOT FIRE WHEN `cs` IMPORTS `panelCommand`.
62
+ Both sides go through `realpathSync` because the installed CLI is reached
63
+ through a symlinked bin, and comparing the raw argument to the module path
64
+ would make this file two different files depending on how it was reached. */
65
+ if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
66
+ panelCommand(process.argv.slice(2)).catch((err) => {
67
+ console.error(err instanceof Error ? err.message : String(err));
68
+ process.exit(1);
69
+ });
70
+ }
@@ -16,14 +16,23 @@
16
16
  * copying it a third time.
17
17
  *
18
18
  * ---------------------------------------------------------------------------
19
- * WHY IT SIGNS IN EVERY TIME INSTEAD OF READING THE STORED SESSION.
19
+ * WHY A SESSION OF ITS OWN, AND WHY THE STORED ONE IS READ THE WAY IT IS.
20
20
  *
21
21
  * The installed CLI keeps one `session.json` on this machine and its long-lived
22
22
  * daemon holds the access token that file names. A second process that reads it
23
- * and lets the client refresh writes a ROTATED token back over the same file,
24
- * and the daemon's token stops working. So v3 takes a fresh session of its own,
25
- * in memory, and never touches the file: `persistSession: false`, and no v3
26
- * client has any storage to write a rotated token into.
23
+ * and lets the client REFRESH writes a ROTATED token back over the same file,
24
+ * and the daemon's token stops working. So when credentials are given, v3 takes
25
+ * a fresh session of its own, in memory, and never touches the file:
26
+ * `persistSession: false`, and no v3 client has any storage to write a rotated
27
+ * token into.
28
+ *
29
+ * ═══ AND WITH NO CREDENTIALS IT READS THAT FILE, BECAUSE THE PERSON TYPES
30
+ * `cs`. ═══ `say`, `answer` and `show` are `cs` subcommands, and a person
31
+ * who ran `cs login` has no v3 environment set and must not be asked to sign in
32
+ * twice. `storedSessionClient` below is that read, in the one shape that cannot
33
+ * rotate anything: the access token on a header, no auth session, no refresh
34
+ * token, nothing written back. The hazard above is about REFRESHING against
35
+ * that file, not about reading it.
27
36
  *
28
37
  * ═══ THAT IS NOW TRUE OF THE COMMANDS AND NOT OF THE PANEL, AND THE DIFFERENCE
29
38
  * IS THE WORD "PROCESS". ═══ recovery-1 Slice 4: `cs start` runs the poll
@@ -35,9 +44,9 @@
35
44
  * token. `signedInClient` must never be called from that path, because taking a
36
45
  * session of its own there is what would recreate the race indoors.
37
46
  *
38
- * So this function now serves `show`, `say`, `answer`, and `cs3 run` as the
39
- * acceptance harness's isolated entry — every one of them a process that is NOT
40
- * the installed daemon, which is exactly the set the paragraph above describes.
47
+ * So this function now serves `show`, `say`, `answer`, and the acceptance
48
+ * harness's isolated `run` entry — every one of them a process that is NOT the
49
+ * installed daemon, which is exactly the set the paragraph above describes.
41
50
  *
42
51
  * ---------------------------------------------------------------------------
43
52
  * ═══ AND THE DAEMON'S CLIENT REFRESHES, WHILE A ONE-SHOT COMMAND'S DOES NOT.
@@ -45,8 +54,8 @@
45
54
  *
46
55
  * `autoRefreshToken` was off for every v3 client, which is right for `show`,
47
56
  * `say` and `answer` — each signs in, does one thing and exits, long inside the
48
- * access token's lifetime — and WRONG for `cs3 run`, which holds one client for
49
- * as long as the daemon lives and hands that same client to every tool call an
57
+ * access token's lifetime — and WRONG for the harness's `run`, which holds one
58
+ * client for as long as the daemon lives and hands that same client to every tool call an
50
59
  * agent makes. Past the token's TTL every poll and every in-flight tool call
51
60
  * would start failing, and the card would sit working with a daemon that could
52
61
  * no longer read or write anything.
@@ -75,8 +84,9 @@
75
84
  * published (`"files": ["dist"]`, `"access": "public"`), and `dist/` is
76
85
  * committed, so a literal password here is one `npm publish` away from the
77
86
  * public registry — a harm about DISTRIBUTION that the loopback guard below,
78
- * which is about where a request is SENT, does nothing to prevent. So there is
79
- * no fallback: unset means refuse, and say where to get them.
87
+ * which is about where a request is SENT, does nothing to prevent. So no
88
+ * password is ever defaulted: unset falls through to the stored `cs login`
89
+ * session, and with neither the command refuses and says what to do.
80
90
  *
81
91
  * ---------------------------------------------------------------------------
82
92
  * ═══ THE LOOPBACK-ONLY GUARD IS GONE, AND THIS IS THE EDIT IT ASKED FOR. ═══
@@ -107,6 +117,7 @@
107
117
  */
108
118
  import { createClient } from '@supabase/supabase-js';
109
119
  import { SUPABASE_URL, SUPABASE_KEY } from '../env.js';
120
+ import { readSession } from '../config.js';
110
121
  /**
111
122
  * How a v3 client holds its session, for the two lifetimes v3 has.
112
123
  *
@@ -122,6 +133,34 @@ export const sessionOptions = (living) => ({
122
133
  the token's lifetime, and the daemon does not. */
123
134
  autoRefreshToken: living,
124
135
  });
136
+ /**
137
+ * ═══ THE PERSON SIGNED IN ONCE, WITH `cs login`, AND `cs say` MUST NOT ASK
138
+ * AGAIN. ═══
139
+ *
140
+ * `say`, `answer` and `show` are `cs` subcommands, so the person who ran
141
+ * `cs login` types them with no environment set at all. This is that session,
142
+ * and it is read in the one shape that CANNOT rotate the token the daemon is
143
+ * holding: the stored access token goes on the Authorization header, there is no
144
+ * auth session on the client, no refresh token is handed over, and nothing is
145
+ * ever written back to `session.json`. A rotation here is exactly the failure
146
+ * the header above describes, and the header cannot happen if no client in this
147
+ * process ever holds a refresh token.
148
+ *
149
+ * An expired access token surfaces as the request's own 401 through
150
+ * `returned()`, which is the truthful failure: `cs start` refreshes the file
151
+ * this reads, so a person whose machine is online has a live token.
152
+ */
153
+ function storedSessionClient() {
154
+ const stored = readSession();
155
+ if (!stored?.access_token) {
156
+ throw new Error('not signed in. Run `cs login`, or set CTRL_SPC_V3_EMAIL and CTRL_SPC_V3_PASSWORD to the '
157
+ + `account this command should act as at ${SUPABASE_URL}`);
158
+ }
159
+ return createClient(SUPABASE_URL, SUPABASE_KEY, {
160
+ auth: sessionOptions(false),
161
+ global: { headers: { Authorization: `Bearer ${stored.access_token}` } },
162
+ });
163
+ }
125
164
  /**
126
165
  * @param living whether this client outlives its access token. The daemon's
127
166
  * does; every one-shot command's does not.
@@ -129,10 +168,8 @@ export const sessionOptions = (living) => ({
129
168
  export async function signedInClient(living = false) {
130
169
  const email = process.env.CTRL_SPC_V3_EMAIL;
131
170
  const password = process.env.CTRL_SPC_V3_PASSWORD;
132
- if (!email || !password) {
133
- throw new Error('set CTRL_SPC_V3_EMAIL and CTRL_SPC_V3_PASSWORD to the account this command '
134
- + `should act as at ${SUPABASE_URL}`);
135
- }
171
+ if (!email || !password)
172
+ return storedSessionClient();
136
173
  const client = createClient(SUPABASE_URL, SUPABASE_KEY, { auth: sessionOptions(living) });
137
174
  const { data, error } = await client.auth.signInWithPassword({ email, password });
138
175
  // Truthfully, and naming both halves of what was attempted: a wrong password
@@ -9,7 +9,7 @@ import { returned } from './client.js';
9
9
  export const LISTENING_WINDOW_MS = 15_000;
10
10
  /** Is this machine still listening?
11
11
  *
12
- * THE ONE PREDICATE, shared by `cs3 show` and the panel, so the two screens
12
+ * THE ONE PREDICATE, shared by `cs show` and the panel, so the two screens
13
13
  * cannot disagree about whether anything can pick a card up.
14
14
  *
15
15
  * ═══ IT TAKES THE ROW, NOT A TIMESTAMP, BECAUSE FRESHNESS IS NO LONGER THE