breakaway 1.5.0-main.19 → 1.5.0-main.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.
- package/README.md +0 -1
- package/package.json +1 -1
- package/scripts/brand-lint.mjs +1 -2
- package/scripts/tasks/cli.js +0 -1
- package/scripts/tasks/hook-config.js +3 -4
- package/scripts/tasks/message-wait.mjs +0 -3
- package/scripts/tasks/session-hook.mjs +0 -7
- package/scripts/tasks/settings.js +5 -37
- package/scripts/tasks.mjs +15 -145
- package/src/init.js +10 -143
- package/src/install.js +1 -1
- package/scripts/plugin.mjs +0 -64
- package/scripts/tasks/mcp.js +0 -189
- package/scripts/tasks/plugin-env.js +0 -37
- package/scripts/tasks/plugin-hooks.js +0 -32
package/README.md
CHANGED
|
@@ -180,7 +180,6 @@ breakaway publishes releases and never deploys an install. Every install, the ow
|
|
|
180
180
|
- **Every merge to `main`**, once CI passes, publishes a GitHub pre-release `vX.Y.Z-main.N` on the `main` channel. It carries the bundle (`breakaway-bundle.tar.gz`: the Worker's files and the web app's `dist`), a `manifest.json` (version, channel, commit, `manual`, and the lowest version it updates from), its signature `manifest.json.sig` (Ed25519, made with a key only the release workflow holds; the public key is `src/release-key.js`), and `SHA256SUMS`. The notes list the merged pull requests by title.
|
|
181
181
|
- **A stable release** `vX.Y.Z` is the owner's: they run the **Release** workflow with the pre-release to promote. The bundle is that pre-release's, unchanged, and the notes cover everything since the last stable, under the release's own words from [`docs/releases/vX.Y.Z.md`](https://github.com/TheAnarchoX/breakaway/tree/main/docs/releases) when it's there.
|
|
182
182
|
- **The CLI** is on npm as [`breakaway`](https://www.npmjs.com/package/breakaway), staged on npm by the same workflow, with provenance, and live once the owner approves it there with 2FA. npm's trusted publishing can't yet read the OIDC identity of a repository as new as this one ([npm/cli#9969](https://github.com/npm/cli/issues/9969)), so until it can, a token that can stage but never publish by itself stands in, in an environment only `main` can use. Every pre-release goes out under the `next` dist-tag, and a stable release as `latest`. `npx breakaway <command>` is `node scripts/tasks.mjs <command>`.
|
|
183
|
-
- **The plugin** for Claude Code (`plugin/`) goes out from the `plugin` branch, never from `main`: Anthropic's plugin directory and this repository's marketplace follow that branch. A stable release runs the **Plugin** workflow, which validates the plugin and moves the branch to the released tag, with `plugin.json`'s version set to the release's. To put a fix out before the next release, the owner runs it by hand on `main` with a release tag, or a commit on `main` a pre-release was made from. As with `site`, a ruleset lets only deploy keys move `plugin`, and the key is the `PLUGIN_DEPLOY_KEY` secret in a `plugin` environment that only `main` can use.
|
|
184
183
|
- **A major release** is one where an install has to do something by hand: a config or binding change, a Durable Object class or migration, a route or cron. Its notes have a **Manual steps** section and its manifest says `manual: true`, which an install's deploy stops on. A change that needs it sets `manual` and `manualSteps` in `release.json`, and the pull request that ships the steps clears them. When the only step is `wrangler deploy` (a new Durable Object class, a cron, a route), it also sets `wranglerDeploy: true`, and an install whose Deploy may run `wrangler deploy` does it itself (the install template's README says when). Data the Durable Object stores changes forward-only and additively, so an install can always go back one release, except across a new Durable Object class, which Cloudflare doesn't roll back.
|
|
185
184
|
- **The version** is `package.json`'s; the release workflow sets it to the pre-release's before it builds. Patches count by themselves: once a stable is out, the pre-releases work toward its next patch. For the next minor or major, pick it as **next** when you run the **Release** workflow (patch, the default, opens nothing): once the stable is published, the workflow opens a pull request setting `package.json` to it, and after it merges the next pre-release is `vX.Y.0-main.1`. That needs **Allow GitHub Actions to create and approve pull requests** on in the repository's Actions settings (if it was off, turn it on and re-run the **next version** job: it opens the pull request from the branch it already made), and a pull request opened with the workflow's token starts no workflows, so close and reopen it, or push to it, for CI to run. At any other time, **Prepare** on the board's GitHub view starts an agent that opens the same pull request. `GET /api/ping` and `GET /api/health` report it as `release`. An install that deploys a stable passes it as the `BREAKAWAY_VERSION` variable, since the bundle was built as the pre-release.
|
|
186
185
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "breakaway",
|
|
3
|
-
"version": "1.5.0-main.
|
|
3
|
+
"version": "1.5.0-main.2",
|
|
4
4
|
"description": "The task board for you and your coding agents: a Cloudflare Worker, its web app, Taskwarrior sync, and the CLI (npx breakaway).",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/scripts/brand-lint.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Lint breakaway's words against the brand guide's mechanical rules (scripts/lib/brand-lint.js).
|
|
4
4
|
* node scripts/brand-lint.mjs [path ...]
|
|
5
|
-
* Paths are files or folders; with none, the web app, brand/, the docs, launch/,
|
|
5
|
+
* Paths are files or folders; with none, the web app, brand/, the docs, launch/, and the README and root
|
|
6
6
|
* markdown files. Exits 1 and prints each finding. A line opts out with `brand-lint-ignore <rule>`.
|
|
7
7
|
*/
|
|
8
8
|
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
@@ -15,7 +15,6 @@ const DEFAULT = [
|
|
|
15
15
|
'brand',
|
|
16
16
|
'docs',
|
|
17
17
|
'launch',
|
|
18
|
-
'plugin',
|
|
19
18
|
'site/README.md',
|
|
20
19
|
'site/content',
|
|
21
20
|
'README.md',
|
package/scripts/tasks/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
7
7
|
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
-
import { boardUrl, configDir, parseEnvFile, readSetting
|
|
10
|
+
import { boardUrl, configDir, parseEnvFile, readSetting } from './settings.js';
|
|
11
11
|
|
|
12
12
|
/** The git root of the current folder, or null outside a repository. */
|
|
13
13
|
function gitRoot() {
|
|
@@ -69,9 +69,8 @@ export function boardConfig(root = projectRoot()) {
|
|
|
69
69
|
/* the CLI says what's wrong with it */
|
|
70
70
|
}
|
|
71
71
|
const { url } = boardUrl({ env: process.env, file, taskrc: readOptional(join(root, '.taskrc')), config });
|
|
72
|
-
// In a cloud session there's no token here: the environment's API credential adds it.
|
|
73
|
-
|
|
74
|
-
const token = settingFrom('TOKEN', { env: process.env, file }).value;
|
|
72
|
+
// In a cloud session there's no token here: the environment's API credential adds it.
|
|
73
|
+
const token = readSetting('TOKEN', { env: process.env, file });
|
|
75
74
|
return { base: url, headers: token ? { Authorization: `Bearer ${token}` } : {} };
|
|
76
75
|
}
|
|
77
76
|
|
|
@@ -17,15 +17,12 @@ import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
17
17
|
import { tmpdir } from 'node:os';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
20
|
-
import { checkoutRunsHooks } from './plugin-hooks.js';
|
|
21
20
|
import { boardConfig, claimedTask, projectRoot } from './hook-config.js';
|
|
22
21
|
import { sessionRequest } from './proxy.js';
|
|
23
22
|
import { waitForMessages } from './session-messages.js';
|
|
24
23
|
|
|
25
24
|
async function main() {
|
|
26
25
|
const root = projectRoot();
|
|
27
|
-
// The plugin's copy of this hook steps aside when the checkout's settings run it too (BRK-159).
|
|
28
|
-
if (checkoutRunsHooks(root)) return 0;
|
|
29
26
|
const claim = claimedTask(root);
|
|
30
27
|
if (!claim?.agent) return 0;
|
|
31
28
|
for await (const _ of process.stdin); // Claude Code sends the hook's input; it isn't needed.
|
|
@@ -10,21 +10,14 @@
|
|
|
10
10
|
* Quiet by design: without a claimed task (.task-session, written by `tasks claim`), with
|
|
11
11
|
* BREAKAWAY_SESSION_LOG=off, or on any error, it does nothing and exits 0. A post
|
|
12
12
|
* that fails leaves its reason in the temp folder, which the CLI's next command here shows (BRK-86).
|
|
13
|
-
*
|
|
14
|
-
* Run by the plugin at SessionStart, it first passes the plugin's settings on to the session's Bash commands (CLI-8).
|
|
15
13
|
*/
|
|
16
|
-
import { checkoutRunsHooks } from './plugin-hooks.js';
|
|
17
14
|
import { boardConfig, claimedTask, dropClaim, projectRoot } from './hook-config.js';
|
|
18
|
-
import { passPluginEnv } from './plugin-env.js';
|
|
19
15
|
import { clearHookFailure, noteHookFailure, sessionRequest } from './proxy.js';
|
|
20
16
|
import { entryFor } from './session-log.js';
|
|
21
17
|
import { CONTEXT_EVENTS, messageOutput, releasedOutput } from './session-messages.js';
|
|
22
18
|
|
|
23
19
|
async function main() {
|
|
24
|
-
passPluginEnv();
|
|
25
20
|
const root = projectRoot();
|
|
26
|
-
// The plugin's copy of this hook steps aside when the checkout's settings run it too (BRK-159).
|
|
27
|
-
if (checkoutRunsHooks(root)) return;
|
|
28
21
|
const claim = claimedTask(root);
|
|
29
22
|
if (!claim) return;
|
|
30
23
|
|
|
@@ -15,21 +15,9 @@ export const NAMES = Object.freeze({
|
|
|
15
15
|
SESSION_LOG: 'BREAKAWAY_SESSION_LOG',
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
* The plugin's settings (CLI-8, docs/specs/IDEA-25-claude-plugin.md, section 2): Claude Code asks for them when the
|
|
20
|
-
* plugin is enabled and hands them to its hooks as CLAUDE_PLUGIN_OPTION_<option>. They come after the CLI's own, so a
|
|
21
|
-
* machine set up with `npx breakaway setup`, or a cloud session's environment, keeps working unchanged.
|
|
22
|
-
*/
|
|
23
|
-
export const PLUGIN_OPTIONS = Object.freeze({
|
|
24
|
-
URL: 'CLAUDE_PLUGIN_OPTION_BOARD_URL',
|
|
25
|
-
TOKEN: 'CLAUDE_PLUGIN_OPTION_TOKEN',
|
|
26
|
-
AGENT: 'CLAUDE_PLUGIN_OPTION_AGENT_NAME',
|
|
27
|
-
});
|
|
28
|
-
|
|
29
18
|
/**
|
|
30
19
|
* A setting's value: from the environment first, then the env file, else `fallback`. The environment always wins,
|
|
31
|
-
* so `BREAKAWAY_URL=… npx breakaway` points one command elsewhere.
|
|
32
|
-
* `settingFrom`.
|
|
20
|
+
* so `BREAKAWAY_URL=… npx breakaway` points one command elsewhere.
|
|
33
21
|
*/
|
|
34
22
|
export function readSetting(key, { env = {}, file = {} }, fallback) {
|
|
35
23
|
const name = NAMES[key];
|
|
@@ -37,23 +25,6 @@ export function readSetting(key, { env = {}, file = {} }, fallback) {
|
|
|
37
25
|
return env[name] || file[name] || fallback;
|
|
38
26
|
}
|
|
39
27
|
|
|
40
|
-
/**
|
|
41
|
-
* A setting's value and where it came from: the environment, then tasks.env, then the plugin's option for it, else
|
|
42
|
-
* `{ value: undefined, from: null }`. `from` names the source, never the value, so `health` can print it.
|
|
43
|
-
* @param {string} key
|
|
44
|
-
* @param {{ env?: Record<string, string | undefined>, file?: Record<string, string> }} sources
|
|
45
|
-
* @returns {{ value: string | undefined, from: 'environment' | 'tasks.env' | 'plugin' | null }}
|
|
46
|
-
*/
|
|
47
|
-
export function settingFrom(key, { env = {}, file = {} }) {
|
|
48
|
-
const name = NAMES[key];
|
|
49
|
-
if (!name) throw new Error(`no setting ${key}`);
|
|
50
|
-
if (env[name]) return { value: env[name], from: 'environment' };
|
|
51
|
-
if (file[name]) return { value: file[name], from: 'tasks.env' };
|
|
52
|
-
const option = PLUGIN_OPTIONS[key];
|
|
53
|
-
if (option && env[option]) return { value: env[option], from: 'plugin' };
|
|
54
|
-
return { value: undefined, from: null };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
28
|
/**
|
|
58
29
|
* This machine's folder for the board (tasks.env, the Taskwarrior credentials, the routines copy):
|
|
59
30
|
* BREAKAWAY_HOME when it's set (one per install, for a machine that uses two), else ~/.config/breakaway.
|
|
@@ -74,19 +45,16 @@ export function taskrcUrl(text) {
|
|
|
74
45
|
}
|
|
75
46
|
|
|
76
47
|
/**
|
|
77
|
-
* The board's base URL, and where it came from: the environment or
|
|
78
|
-
*
|
|
79
|
-
* the plugin's `board_url` (CLI-8: a setting of the person's, so anything the machine or checkout says comes first),
|
|
80
|
-
* else null: nothing says which board.
|
|
48
|
+
* The board's base URL, and where it came from: the environment or env file (BREAKAWAY_URL), else the checkout's own .taskrc (`sync.server.url`, so the CLI and Taskwarrior agree),
|
|
49
|
+
* else the install's breakaway.config.json (`url`), else null: nothing says which board.
|
|
81
50
|
*/
|
|
82
51
|
export function boardUrl({ env = {}, file = {}, taskrc = null, config = null }) {
|
|
83
52
|
const pick = () => {
|
|
84
|
-
|
|
85
|
-
if (
|
|
53
|
+
const set = readSetting('URL', { env, file });
|
|
54
|
+
if (set) return { url: set, from: 'setting' };
|
|
86
55
|
const rc = taskrcUrl(taskrc);
|
|
87
56
|
if (rc) return { url: rc, from: '.taskrc' };
|
|
88
57
|
if (config?.url) return { url: config.url, from: 'config' };
|
|
89
|
-
if (env[PLUGIN_OPTIONS.URL]) return { url: env[PLUGIN_OPTIONS.URL], from: 'plugin' };
|
|
90
58
|
return { url: null, from: 'default' };
|
|
91
59
|
};
|
|
92
60
|
const { url, from } = pick();
|
package/scripts/tasks.mjs
CHANGED
|
@@ -49,8 +49,6 @@ import { checkInstall, confirmInstall, tokenTarget, unverifiedInstall } from './
|
|
|
49
49
|
import { NO_TERMINAL, ask as askIn } from './tasks/ask.js';
|
|
50
50
|
import {
|
|
51
51
|
CLI_PACKAGE,
|
|
52
|
-
PLUGIN_BRANCH,
|
|
53
|
-
PLUGIN_REPO,
|
|
54
52
|
PROMPT_SECTIONS,
|
|
55
53
|
initCommitMessage,
|
|
56
54
|
initPlan,
|
|
@@ -83,20 +81,10 @@ import {
|
|
|
83
81
|
unknownSubcommand,
|
|
84
82
|
} from './tasks/cli.js';
|
|
85
83
|
import { keepLines } from './tasks/keep.js';
|
|
86
|
-
import { checkMcp, headersRepo, mcpAgent, mcpConfig, mcpHeaders, mcpLines } from './tasks/mcp.js';
|
|
87
84
|
import { mergeViews, pelotonLines, pelotonPost, pickPeloton } from './tasks/peloton.js';
|
|
88
85
|
import { CLI_VERSION } from '../src/cli-version.js';
|
|
89
86
|
import { parseInstall, secretName } from '../src/install.js';
|
|
90
|
-
import {
|
|
91
|
-
NAMES,
|
|
92
|
-
boardUrl,
|
|
93
|
-
configDir,
|
|
94
|
-
parseEnvFile,
|
|
95
|
-
readSetting,
|
|
96
|
-
settingFrom,
|
|
97
|
-
taskrcFixes,
|
|
98
|
-
tildePath,
|
|
99
|
-
} from './tasks/settings.js';
|
|
87
|
+
import { NAMES, boardUrl, configDir, parseEnvFile, readSetting, taskrcFixes, tildePath } from './tasks/settings.js';
|
|
100
88
|
|
|
101
89
|
const CONFIG_DIR = configDir({ env: process.env, home: homedir() });
|
|
102
90
|
const ENV_FILE = join(CONFIG_DIR, 'tasks.env');
|
|
@@ -247,11 +235,6 @@ Reading (list, next, claim, and add work in this checkout's repos
|
|
|
247
235
|
github the checkout's repository on GitHub: open pull requests, checks, reviews, CI, deploys, alerts [--sync] [--repo <slug>]
|
|
248
236
|
hook session|wait the Claude Code session hooks a repository's .claude/settings.json runs (npx breakaway hook session)
|
|
249
237
|
health the server's state
|
|
250
|
-
mcp print the claude mcp add line and the .mcp.json entry that connect an MCP client to the board's
|
|
251
|
-
/mcp from this checkout, with the token as $BREAKAWAY_TOKEN, never its value. Writes nothing
|
|
252
|
-
--check …or check the server answers: initialize and tools/list, or the board's error
|
|
253
|
-
--headers …or print the headers Claude Code sends to /mcp as JSON, the token included: the plugin's
|
|
254
|
-
headersHelper. Only Authorization outside a repository the board tracks
|
|
255
238
|
export every task (all repositories, statuses, and horizons) as JSON, checked against health's count [--out <file>]
|
|
256
239
|
connections is everything the board leans on wired up: GitHub, Cloudflare, Claude, sync, push; the fix for each that isn't
|
|
257
240
|
|
|
@@ -309,12 +292,8 @@ Working
|
|
|
309
292
|
In a terminal it asks for each section of the agent prompt (Enter takes the default), or
|
|
310
293
|
takes them from --building --checks --pull-requests --direction --dependency-updates
|
|
311
294
|
--never-share <text>; --defaults takes the default for the rest without asking
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
--update refresh the copied files (core, Taskwarrior files, release helpers) in a pull request when
|
|
315
|
-
they're older than this checkout's, and move a repository with copies to the plugin; the
|
|
316
|
-
repository's own (its prompt, AGENTS.md) are never touched
|
|
317
|
-
--copies copy the tasks skill and the session hooks instead of using the plugin (with or without --update)
|
|
295
|
+
--update refresh the copied files (the CLI, core, skill, Taskwarrior files) in a pull request when they're
|
|
296
|
+
older than this checkout's; the repository's own (its prompt, AGENTS.md) are never touched
|
|
318
297
|
--pipeline also add the deploy flow: .github/breakaway-pipeline.json for the Workers --staging <name> and
|
|
319
298
|
--production <name> (asked in a terminal; default <slug>-staging and <slug>), what it renders,
|
|
320
299
|
and a minimal CI when the repository has no workflow. Only new files: never one it already has
|
|
@@ -382,9 +361,7 @@ Projects: ideas and routines are the board's; repos lists each repository's area
|
|
|
382
361
|
|
|
383
362
|
Settings: BREAKAWAY_TOKEN, BREAKAWAY_URL, BREAKAWAY_AGENT, BREAKAWAY_REPO, from the environment or tasks.env in
|
|
384
363
|
$BREAKAWAY_HOME (default ~/.config/breakaway).
|
|
385
|
-
Without BREAKAWAY_URL the board is this checkout's .taskrc sync.server.url. See docs/tasks.md#another-install
|
|
386
|
-
Last come the breakaway plugin's settings for Claude Code (board_url, token, agent_name). health says which
|
|
387
|
-
source each setting came from.`;
|
|
364
|
+
Without BREAKAWAY_URL the board is this checkout's .taskrc sync.server.url. See docs/tasks.md#another-install.`;
|
|
388
365
|
|
|
389
366
|
// ---- settings ----------------------------------------------------------------------------
|
|
390
367
|
|
|
@@ -393,11 +370,8 @@ function readEnvFile() {
|
|
|
393
370
|
}
|
|
394
371
|
|
|
395
372
|
const fileEnv = readEnvFile();
|
|
396
|
-
/**
|
|
397
|
-
|
|
398
|
-
* when the CLI runs in a Claude Code session with the breakaway plugin (CLI-8).
|
|
399
|
-
*/
|
|
400
|
-
const setting = (key, fallback) => settingFrom(key, { env: process.env, file: fileEnv }).value ?? fallback;
|
|
373
|
+
/** A setting by its key in NAMES (scripts/tasks/settings.js): `TOKEN` reads BREAKAWAY_TOKEN. */
|
|
374
|
+
const setting = (key, fallback) => readSetting(key, { env: process.env, file: fileEnv }, fallback);
|
|
401
375
|
const BOARD = boardUrl({
|
|
402
376
|
env: process.env,
|
|
403
377
|
file: fileEnv,
|
|
@@ -443,11 +417,9 @@ const FLAGS = new Set([
|
|
|
443
417
|
'update',
|
|
444
418
|
'defaults',
|
|
445
419
|
'package',
|
|
446
|
-
'check',
|
|
447
|
-
'headers',
|
|
448
420
|
]);
|
|
449
421
|
/** Flags only in repos init (BRK-91): --pipeline takes a file in repos modify, and is a flag there. */
|
|
450
|
-
const INIT_FLAGS = new Set(['pipeline'
|
|
422
|
+
const INIT_FLAGS = new Set(['pipeline']);
|
|
451
423
|
|
|
452
424
|
function parse(argv) {
|
|
453
425
|
const positional = [];
|
|
@@ -502,7 +474,7 @@ async function call(method, path, body, { soft = false } = {}) {
|
|
|
502
474
|
warnIfStale(res.headers.get('X-Tasks-Cli'), res.headers.get('X-Tasks-Release'));
|
|
503
475
|
if (res.status === 401 && !token)
|
|
504
476
|
fail(
|
|
505
|
-
`no token. Set BREAKAWAY_TOKEN, put it in ${ENV_FILE},
|
|
477
|
+
`no token. Set BREAKAWAY_TOKEN, put it in ${ENV_FILE}, or add it as an API credential in the cloud environment (see docs/tasks.md#cloud-agents).`,
|
|
506
478
|
);
|
|
507
479
|
const data = await res.json().catch(() => ({
|
|
508
480
|
// The board always answers in JSON, so anything else came from something in between.
|
|
@@ -825,29 +797,6 @@ function changesFrom(o) {
|
|
|
825
797
|
return c;
|
|
826
798
|
}
|
|
827
799
|
|
|
828
|
-
/** Where the board's address, token, and agent name came from (CLI-8): the sources only, never a value. */
|
|
829
|
-
function settingSources() {
|
|
830
|
-
const sources = { env: process.env, file: fileEnv };
|
|
831
|
-
return { url: BOARD.from, token: settingFrom('TOKEN', sources).from, agent: settingFrom('AGENT', sources).from };
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
const SOURCE = {
|
|
835
|
-
environment: 'the environment',
|
|
836
|
-
'tasks.env': ENV_FILE,
|
|
837
|
-
'.taskrc': "this checkout's .taskrc",
|
|
838
|
-
config: "the install's breakaway.config.json",
|
|
839
|
-
plugin: "the breakaway plugin's settings",
|
|
840
|
-
};
|
|
841
|
-
|
|
842
|
-
/** settingSources() in a line: `address from …, token from …, agent name from …`. */
|
|
843
|
-
function describeSources({ url, token, agent: name }) {
|
|
844
|
-
return [
|
|
845
|
-
`address from ${SOURCE[url] ?? 'nowhere'}`,
|
|
846
|
-
`token ${token ? `from ${SOURCE[token]}` : "not set (a cloud session's proxy may add it)"}`,
|
|
847
|
-
`agent name ${name ? `from ${SOURCE[name]}` : 'not set'}`,
|
|
848
|
-
].join(', ');
|
|
849
|
-
}
|
|
850
|
-
|
|
851
800
|
const commands = {
|
|
852
801
|
/** The Claude Code session hooks (BRK-7), so a repository's .claude/settings.json runs them through npx. */
|
|
853
802
|
async hook() {
|
|
@@ -1624,68 +1573,12 @@ const commands = {
|
|
|
1624
1573
|
return out.join('\n');
|
|
1625
1574
|
});
|
|
1626
1575
|
},
|
|
1627
|
-
/** How to connect an MCP client to the board's /mcp from this checkout, and --check whether it answers (CLI-6). */
|
|
1628
|
-
async mcp() {
|
|
1629
|
-
// --headers is the plugin's headersHelper: it asks the board itself, and never fails (CLI-9).
|
|
1630
|
-
const { slug } = opts.headers ? { slug: null } : await checkoutRepo();
|
|
1631
|
-
let branch = null;
|
|
1632
|
-
try {
|
|
1633
|
-
branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
1634
|
-
encoding: 'utf8',
|
|
1635
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1636
|
-
}).trim();
|
|
1637
|
-
} catch {
|
|
1638
|
-
/* not a git checkout */
|
|
1639
|
-
}
|
|
1640
|
-
const name = mcpAgent({ named: opts.as ?? setting('AGENT'), branch, fallback: agent() });
|
|
1641
|
-
if (opts.headers) {
|
|
1642
|
-
const token = setting('TOKEN');
|
|
1643
|
-
let remote = null;
|
|
1644
|
-
try {
|
|
1645
|
-
remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
1646
|
-
encoding: 'utf8',
|
|
1647
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1648
|
-
});
|
|
1649
|
-
} catch {
|
|
1650
|
-
/* not a git checkout, or no origin */
|
|
1651
|
-
}
|
|
1652
|
-
// Claude Code says which server it's connecting: the plugin's board_url, when the CLI's settings don't name one.
|
|
1653
|
-
const server = process.env.CLAUDE_CODE_MCP_SERVER_URL?.replace(/\/mcp\/?$/u, '');
|
|
1654
|
-
const repo = await headersRepo({
|
|
1655
|
-
base: BASE ?? (server?.startsWith('http') ? server : null),
|
|
1656
|
-
token,
|
|
1657
|
-
named: opts.repo ?? setting('REPO'),
|
|
1658
|
-
remote,
|
|
1659
|
-
fetch,
|
|
1660
|
-
});
|
|
1661
|
-
// Standard output is Claude Code's, and only the headers go there: never --json's wrapping, never a line of text.
|
|
1662
|
-
console.log(JSON.stringify(mcpHeaders({ token, agent: name, repo })));
|
|
1663
|
-
return;
|
|
1664
|
-
}
|
|
1665
|
-
const tokenVar = envName('TOKEN');
|
|
1666
|
-
const config = mcpConfig({ url: BASE, agent: name, repo: slug, tokenVar });
|
|
1667
|
-
if (!opts.check) {
|
|
1668
|
-
print(config, () => mcpLines(config, { repo: slug, tokenVar }).join('\n'));
|
|
1669
|
-
return;
|
|
1670
|
-
}
|
|
1671
|
-
const checked = await checkMcp({
|
|
1672
|
-
endpoint: config.endpoint,
|
|
1673
|
-
token: setting('TOKEN'),
|
|
1674
|
-
agent: name,
|
|
1675
|
-
repo: slug,
|
|
1676
|
-
fetch,
|
|
1677
|
-
});
|
|
1678
|
-
print(checked, (c) => c.lines.join('\n'));
|
|
1679
|
-
if (!checked.ok) process.exitCode = 1;
|
|
1680
|
-
},
|
|
1681
1576
|
async health() {
|
|
1682
|
-
|
|
1683
|
-
print({ ...(await call('GET', 'health')), settings }, (h) =>
|
|
1577
|
+
print(await call('GET', 'health'), (h) =>
|
|
1684
1578
|
[
|
|
1685
1579
|
h.ok ? 'The task server is healthy.' : `The task server can't read its history: ${h.replicaError}`,
|
|
1686
1580
|
` ${h.tasks.pending} open of ${h.tasks.total} tasks, ${h.versions} versions`,
|
|
1687
1581
|
` snapshot: ${h.snapshot ? `${h.snapshot.created.slice(0, 16)}, ${h.snapshot.versionsSince} versions since` : 'none'}`,
|
|
1688
|
-
` settings: ${describeSources(settings)}`,
|
|
1689
1582
|
].join('\n'),
|
|
1690
1583
|
);
|
|
1691
1584
|
},
|
|
@@ -2126,10 +2019,7 @@ async function initRepo(slug) {
|
|
|
2126
2019
|
/* not a git checkout */
|
|
2127
2020
|
}
|
|
2128
2021
|
const answers = update || readTarget(promptPathOf(repo)) !== null ? {} : await promptAnswers();
|
|
2129
|
-
const plugin = !opts.copies;
|
|
2130
2022
|
const plan = initPlan({
|
|
2131
|
-
plugin,
|
|
2132
|
-
pluginReleased: plugin ? pluginReleased() : false,
|
|
2133
2023
|
repo,
|
|
2134
2024
|
board: board ?? 'TheAnarchoX/breakaway',
|
|
2135
2025
|
url: BASE,
|
|
@@ -2181,7 +2071,7 @@ async function initRepo(slug) {
|
|
|
2181
2071
|
: []),
|
|
2182
2072
|
...(plan.removals.length
|
|
2183
2073
|
? [
|
|
2184
|
-
`${opts['dry-run'] ? 'Would remove' : 'Removing'} ${plan.removals.length} files the
|
|
2074
|
+
`${opts['dry-run'] ? 'Would remove' : 'Removing'} ${plan.removals.length} files of the old copy of the CLI (npx breakaway replaces it):`,
|
|
2185
2075
|
...plan.removals.map((p) => ` ${p}`),
|
|
2186
2076
|
]
|
|
2187
2077
|
: []),
|
|
@@ -2244,8 +2134,7 @@ async function initRepo(slug) {
|
|
|
2244
2134
|
);
|
|
2245
2135
|
}
|
|
2246
2136
|
const first = initCommitMessage(repo.slug, {
|
|
2247
|
-
by: `npx breakaway repos init ${repo.slug}${opts.
|
|
2248
|
-
plugin: plan.plugin,
|
|
2137
|
+
by: `npx breakaway repos init ${repo.slug}${opts.pipeline ? ' --pipeline' : ''}${opts.package ? ' --package' : ''}`,
|
|
2249
2138
|
});
|
|
2250
2139
|
const title = update ? "Update the task board's agent files" : first.title;
|
|
2251
2140
|
git(
|
|
@@ -2254,7 +2143,7 @@ async function initRepo(slug) {
|
|
|
2254
2143
|
title,
|
|
2255
2144
|
'-m',
|
|
2256
2145
|
update
|
|
2257
|
-
? `The board's core,
|
|
2146
|
+
? `The board's core, skill, release helpers, and Taskwarrior files as they are in ${board ?? 'breakaway'} now, and the session hooks run through npx, so an old copy of the CLI is removed: run it as npx ${CLI_PACKAGE}. This repository's own files are unchanged. Updated by npx ${CLI_PACKAGE} repos init ${repo.slug} --update.`
|
|
2258
2147
|
: `${first.body}${starter.files.length ? ` It also adds the deploy and release flows, rendered from ${starter.files[0].path}.` : ''}`,
|
|
2259
2148
|
);
|
|
2260
2149
|
const pushed = spawnSync('git', ['-C', dir, 'push', '-u', 'origin', empty ? `HEAD:refs/heads/${branch}` : work], {
|
|
@@ -2280,7 +2169,7 @@ async function initRepo(slug) {
|
|
|
2280
2169
|
title,
|
|
2281
2170
|
'--body',
|
|
2282
2171
|
update
|
|
2283
|
-
? `The task board's copied files (the core and stub,
|
|
2172
|
+
? `The task board's copied files (its CLI, the core and stub, the tasks skill, and the Taskwarrior files) as they are on the board's repository now, updated by \`npx breakaway repos init ${repo.slug} --update\`. This repository's own files (its agent prompt, AGENTS.md, .taskrc, .envrc, package.json, .claude/settings.json) are unchanged.`
|
|
2284
2173
|
: `The files the task board's agents need to claim and work a task in this repository, added by \`npx breakaway repos init ${repo.slug}\`. Nothing that was there is changed. If this repository's linter reads plain JavaScript, exclude the copied scripts (tools/tasks/ and the release helpers) from it.${plan.todo.length ? `\n\nStill to do:\n${plan.todo.map((t) => `- ${t}`).join('\n')}` : ''}`,
|
|
2285
2174
|
],
|
|
2286
2175
|
{ encoding: 'utf8' },
|
|
@@ -2300,24 +2189,6 @@ async function initRepo(slug) {
|
|
|
2300
2189
|
console.log(['', ...next].join('\n'));
|
|
2301
2190
|
}
|
|
2302
2191
|
|
|
2303
|
-
/**
|
|
2304
|
-
* Whether breakaway's plugin is out (BRK-159): its marketplace gives it out from the plugin branch, which a stable
|
|
2305
|
-
* release moves, so until that branch is there, repos init copies the skill and hooks instead. Null when GitHub can't
|
|
2306
|
-
* be reached, which repos init says, and copies too.
|
|
2307
|
-
*/
|
|
2308
|
-
function pluginReleased() {
|
|
2309
|
-
try {
|
|
2310
|
-
const heads = execFileSync(
|
|
2311
|
-
'git',
|
|
2312
|
-
['ls-remote', '--heads', `https://github.com/${PLUGIN_REPO}.git`, `refs/heads/${PLUGIN_BRANCH}`],
|
|
2313
|
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 20_000 },
|
|
2314
|
-
);
|
|
2315
|
-
return heads.trim() !== '';
|
|
2316
|
-
} catch {
|
|
2317
|
-
return null;
|
|
2318
|
-
}
|
|
2319
|
-
}
|
|
2320
|
-
|
|
2321
2192
|
/**
|
|
2322
2193
|
* The staging and production Workers for repos init --pipeline (BRK-91): --staging and --production, else asked in a
|
|
2323
2194
|
* terminal, else `<slug>-staging` and `<slug>`.
|
|
@@ -2540,10 +2411,9 @@ if (opts.help || command === 'help') {
|
|
|
2540
2411
|
process.exitCode = (await import('./tasks/pipeline.js')).run(args, opts);
|
|
2541
2412
|
} else if (!commands[command]) {
|
|
2542
2413
|
fail(`no command "${command}". npx breakaway help lists them.`);
|
|
2543
|
-
} else if (!BASE && command !== 'init-secrets'
|
|
2544
|
-
// The hooks stay quiet without a board (a plugin installed but not set up, CLI-8): session-hook.mjs checks for itself.
|
|
2414
|
+
} else if (!BASE && command !== 'init-secrets') {
|
|
2545
2415
|
fail(
|
|
2546
|
-
`no board address. Set BREAKAWAY_URL (in the environment or ${ENV_FILE}), sync.server.url in this checkout's .taskrc
|
|
2416
|
+
`no board address. Set BREAKAWAY_URL (in the environment or ${ENV_FILE}), or sync.server.url in this checkout's .taskrc: see docs/tasks.md#another-install.`,
|
|
2547
2417
|
);
|
|
2548
2418
|
} else if (unknownSubcommand(command, args[0])) {
|
|
2549
2419
|
fail(unknownSubcommand(command, args[0]));
|
package/src/init.js
CHANGED
|
@@ -62,101 +62,6 @@ const TARGET_DIR = 'tools/tasks/';
|
|
|
62
62
|
*/
|
|
63
63
|
export const MANIFEST = `${TARGET_DIR}copied.json`;
|
|
64
64
|
const GITIGNORE = ['.task/', '.task-session', '.env'];
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* breakaway's Claude Code plugin (docs/specs/IDEA-25-claude-plugin.md, section 3): repos init sets a repository up with
|
|
68
|
-
* it by default (BRK-158), so .claude/settings.json names breakaway's marketplace and turns the plugin on instead of
|
|
69
|
-
* running the session hooks, and the tasks skill comes with the plugin instead of a copy. The marketplace lives on
|
|
70
|
-
* breakaway's own repository, and gives out the plugin from its `plugin` branch, which moves at a stable release.
|
|
71
|
-
*/
|
|
72
|
-
export const PLUGIN_REPO = 'TheAnarchoX/breakaway';
|
|
73
|
-
export const PLUGIN_MARKETPLACE = 'breakaway';
|
|
74
|
-
export const PLUGIN = `breakaway@${PLUGIN_MARKETPLACE}`;
|
|
75
|
-
export const PLUGIN_BRANCH = 'plugin';
|
|
76
|
-
|
|
77
|
-
/** The two keys .claude/settings.json gets for the plugin: breakaway's marketplace, and the plugin turned on. */
|
|
78
|
-
export function pluginSettings() {
|
|
79
|
-
return {
|
|
80
|
-
extraKnownMarketplaces: { [PLUGIN_MARKETPLACE]: { source: { source: 'github', repo: PLUGIN_REPO } } },
|
|
81
|
-
enabledPlugins: { [PLUGIN]: true },
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Whether a hook command is one of the session hooks repos init wrote: through npx, any version, or an old copy's. */
|
|
86
|
-
const BOARD_HOOK =
|
|
87
|
-
/\bbreakaway(?:@[^\s"]+)? hook (?:session|wait)\b|scripts\/tasks\/(?:session-hook|message-wait)\.mjs/u;
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* settings.json moved to the plugin (repos init --update): the session hooks repos init wrote are gone, with any event
|
|
91
|
-
* left empty, and the plugin's two keys are there. A plugin the settings turn off stays off, and the rest is untouched.
|
|
92
|
-
* Null when it isn't JSON, so the caller says what to do instead.
|
|
93
|
-
*/
|
|
94
|
-
export function withPluginSettings(text) {
|
|
95
|
-
let settings;
|
|
96
|
-
try {
|
|
97
|
-
settings = JSON.parse(text);
|
|
98
|
-
} catch {
|
|
99
|
-
return null;
|
|
100
|
-
}
|
|
101
|
-
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return null;
|
|
102
|
-
const hooks = settings.hooks;
|
|
103
|
-
if (hooks && typeof hooks === 'object') {
|
|
104
|
-
for (const [event, groups] of Object.entries(hooks)) {
|
|
105
|
-
if (!Array.isArray(groups)) continue;
|
|
106
|
-
const kept = groups
|
|
107
|
-
.map((group) =>
|
|
108
|
-
Array.isArray(group?.hooks)
|
|
109
|
-
? { ...group, hooks: group.hooks.filter((h) => !BOARD_HOOK.test(String(h?.command ?? ''))) }
|
|
110
|
-
: group,
|
|
111
|
-
)
|
|
112
|
-
.filter((group, i) => !(Array.isArray(group?.hooks) && !group.hooks.length && groups[i].hooks.length));
|
|
113
|
-
if (kept.length) hooks[event] = kept;
|
|
114
|
-
else delete hooks[event];
|
|
115
|
-
}
|
|
116
|
-
if (!Object.keys(hooks).length) delete settings.hooks;
|
|
117
|
-
}
|
|
118
|
-
const want = pluginSettings();
|
|
119
|
-
settings.extraKnownMarketplaces = {
|
|
120
|
-
...want.extraKnownMarketplaces,
|
|
121
|
-
...settings.extraKnownMarketplaces,
|
|
122
|
-
};
|
|
123
|
-
if (typeof settings.enabledPlugins?.[PLUGIN] !== 'boolean')
|
|
124
|
-
settings.enabledPlugins = { ...settings.enabledPlugins, ...want.enabledPlugins };
|
|
125
|
-
return `${JSON.stringify(settings, null, 2)}\n`;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Whether two JSON texts hold the same value, whatever their layout. */
|
|
129
|
-
const sameJson = (a, b) => {
|
|
130
|
-
try {
|
|
131
|
-
return JSON.stringify(JSON.parse(a)) === JSON.stringify(JSON.parse(b));
|
|
132
|
-
} catch {
|
|
133
|
-
return false;
|
|
134
|
-
}
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
/** Whether settings.json already has the plugin: breakaway's marketplace, and the plugin named in enabledPlugins. */
|
|
138
|
-
function hasPlugin(text) {
|
|
139
|
-
try {
|
|
140
|
-
const settings = JSON.parse(text);
|
|
141
|
-
return (
|
|
142
|
-
Boolean(settings?.extraKnownMarketplaces?.[PLUGIN_MARKETPLACE]) && PLUGIN in (settings?.enabledPlugins ?? {})
|
|
143
|
-
);
|
|
144
|
-
} catch {
|
|
145
|
-
return false;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Why repos init copies the tasks skill and the session hooks when it was asked for the plugin: the plugin isn't out
|
|
151
|
-
* yet (its branch isn't there), or GitHub couldn't say (`released` null).
|
|
152
|
-
*/
|
|
153
|
-
export function pluginPendingNote(slug, released = false) {
|
|
154
|
-
const why =
|
|
155
|
-
released === null
|
|
156
|
-
? `GitHub couldn't say whether breakaway's plugin is out yet (the ${PLUGIN_BRANCH} branch on ${PLUGIN_REPO})`
|
|
157
|
-
: `breakaway's plugin isn't out yet (${PLUGIN_REPO} has no ${PLUGIN_BRANCH} branch until a stable release moves it)`;
|
|
158
|
-
return `${why}, so this copies the tasks skill and the session hooks instead. Once it's out, npx breakaway repos init ${slug} --update moves the repository to the plugin.`;
|
|
159
|
-
}
|
|
160
65
|
/** Pinned to LF so the shell scripts run on a checkout with core.autocrlf=true (BRK-41). */
|
|
161
66
|
const GITATTRIBUTES = ['scripts/task text eol=lf', '.envrc text eol=lf'];
|
|
162
67
|
|
|
@@ -170,13 +75,10 @@ export function boardSources(read) {
|
|
|
170
75
|
}
|
|
171
76
|
|
|
172
77
|
/** The first commit's message for a repository set up from scratch: its title and body, the CLI's and the board's alike. */
|
|
173
|
-
export function initCommitMessage(slug, { by = `npx breakaway repos init ${slug}
|
|
174
|
-
const agentParts = plugin
|
|
175
|
-
? "breakaway's Claude Code plugin, turned on in .claude/settings.json (it brings the tasks skill and the session hooks)"
|
|
176
|
-
: 'the session hooks (they run the CLI through npx), the tasks skill';
|
|
78
|
+
export function initCommitMessage(slug, { by = `npx breakaway repos init ${slug}` } = {}) {
|
|
177
79
|
return {
|
|
178
80
|
title: "Set up the task board's agent files",
|
|
179
|
-
body: `What a board-started agent needs to claim and work a task here: the agent prompt, the board's core,
|
|
81
|
+
body: `What a board-started agent needs to claim and work a task here: the agent prompt, the board's core, the session hooks (they run the CLI through npx), the tasks skill, AGENTS.md, and Taskwarrior with direnv. Added by ${by}.`,
|
|
180
82
|
};
|
|
181
83
|
}
|
|
182
84
|
|
|
@@ -408,20 +310,14 @@ export function skillFor(text, board, repo = null) {
|
|
|
408
310
|
}
|
|
409
311
|
|
|
410
312
|
/** A starter AGENTS.md: how this repository works with the board. The owner adds how to build here. */
|
|
411
|
-
export function agentsMd(repo, board, dir = DEFAULT_DIR
|
|
412
|
-
const skill = plugin
|
|
413
|
-
? `Use the \`tasks\` skill (from breakaway's Claude Code plugin, which \`.claude/settings.json\` turns on) and the CLI, \`npx breakaway\` (the \`breakaway\` package on npm), to claim, comment, and hand over. It works in this checkout's repository, so \`list\` and \`next\` show only this repository's tasks. The skill is the board's, written for any repository: this repository's areas are above, and its rules are here.`
|
|
414
|
-
: `Use the \`tasks\` skill (\`${SKILL}\`) and the CLI, \`npx breakaway\` (the \`breakaway\` package on npm), to claim, comment, and hand over. It works in this checkout's repository, so \`list\` and \`next\` show only this repository's tasks. The skill is breakaway's, written for this repository's areas and prompt: where it names breakaway's own files or rules, the board's part applies and the rest doesn't.`;
|
|
415
|
-
const copied = plugin
|
|
416
|
-
? `\`tools/tasks/\`, the release helpers in \`scripts/\`, and \`${PIPELINE_SKILL}\` come from [${board}](https://github.com/${board}). Don't edit them here: change them there. \`${MANIFEST}\` lists every file it copied, and \`repos init --update\` replaces only those: a file it doesn't list is this repository's own, even at a path breakaway copies to. \`.claude/settings.json\` turns on breakaway's plugin (\`${PLUGIN}\`), which brings the \`tasks\` skill, its commands, and the session hooks that show a cloud agent's output on its task, so this repository carries no copy of them.`
|
|
417
|
-
: `\`tools/tasks/\`, the release helpers in \`scripts/\`, \`${SKILL}\`, and \`${PIPELINE_SKILL}\` come from [${board}](https://github.com/${board}). Don't edit them here: change them there. \`${MANIFEST}\` lists every file it copied, and \`repos init --update\` replaces only those: a file it doesn't list is this repository's own, even at a path breakaway copies to. \`.claude/settings.json\` holds the session hooks that show a cloud agent's output on its task, and they run through \`npx\`, so this repository carries no copy of the CLI.`;
|
|
313
|
+
export function agentsMd(repo, board, dir = DEFAULT_DIR) {
|
|
418
314
|
return `# Agent instructions
|
|
419
315
|
|
|
420
316
|
<!-- Started by \`npx breakaway repos init\` (breakaway's task board). Add how to build here: setup, tests, style, and anything agents must never do. -->
|
|
421
317
|
|
|
422
|
-
- **Work lives on the task board.** This repository's tasks are in the areas ${areaList(repo)}.
|
|
318
|
+
- **Work lives on the task board.** This repository's tasks are in the areas ${areaList(repo)}. Use the \`tasks\` skill (\`${SKILL}\`) and the CLI, \`npx breakaway\` (the \`breakaway\` package on npm), to claim, comment, and hand over. It works in this checkout's repository, so \`list\` and \`next\` show only this repository's tasks. The skill is breakaway's, written for this repository's areas and prompt: where it names breakaway's own files or rules, the board's part applies and the rest doesn't.
|
|
423
319
|
- **Agents started by the board** follow [\`${promptPathOf(repo)}\`](${promptPathOf(repo)}), which starts with the board's core, \`tools/tasks/prompts/core.md\`.
|
|
424
|
-
- **Copied files.** ${copied
|
|
320
|
+
- **Copied files.** \`tools/tasks/\`, the release helpers in \`scripts/\`, \`${SKILL}\`, and \`${PIPELINE_SKILL}\` come from [${board}](https://github.com/${board}). Don't edit them here: change them there. \`${MANIFEST}\` lists every file it copied, and \`repos init --update\` replaces only those: a file it doesn't list is this repository's own, even at a path breakaway copies to. \`.claude/settings.json\` holds the session hooks that show a cloud agent's output on its task, and they run through \`npx\`, so this repository carries no copy of the CLI.
|
|
425
321
|
- **Taskwarrior** (optional): \`scripts/task\`, or plain \`task\` with direnv after \`direnv allow\`, uses the board with this checkout's own \`.task/\` database, in the \`${repo.slug}\` context. \`npx breakaway setup\` connects the machine once.
|
|
426
322
|
- **Changes reach \`${repo.defaultBranch || 'main'}\` through pull requests**, which the owner merges. Never merge, force-push, or rewrite \`${repo.defaultBranch || 'main'}\`.
|
|
427
323
|
- **Never put a secret or token** in a file, task, comment, or pull request. The board's token lives in \`${dir}/tasks.env\` (or \`$BREAKAWAY_HOME/tasks.env\`) or the cloud environment's credentials, never in this repository.
|
|
@@ -472,8 +368,6 @@ export function initPlan({
|
|
|
472
368
|
url,
|
|
473
369
|
configDir = DEFAULT_DIR,
|
|
474
370
|
update = false,
|
|
475
|
-
plugin = true,
|
|
476
|
-
pluginReleased = true,
|
|
477
371
|
sections = {},
|
|
478
372
|
defaulted = [],
|
|
479
373
|
}) {
|
|
@@ -482,9 +376,6 @@ export function initPlan({
|
|
|
482
376
|
const current = [];
|
|
483
377
|
const notes = [];
|
|
484
378
|
const todo = [];
|
|
485
|
-
// The plugin unless the repository asked for copies (--copies), and copies while the plugin isn't out yet.
|
|
486
|
-
const usePlugin = plugin && pluginReleased === true;
|
|
487
|
-
if (plugin && !usePlugin) notes.push(pluginPendingNote(repo.slug, pluginReleased));
|
|
488
379
|
const add = (path, content, extra = {}) => {
|
|
489
380
|
if (readTarget(path) !== null) skipped.push(path);
|
|
490
381
|
else files.push({ path, content, ...extra });
|
|
@@ -564,22 +455,8 @@ export function initPlan({
|
|
|
564
455
|
`${leftover.join(', ')} came with the old copy of the CLI. Nothing here needs them now: delete the ones this repository doesn't use itself.`,
|
|
565
456
|
);
|
|
566
457
|
|
|
567
|
-
add(
|
|
568
|
-
|
|
569
|
-
`${JSON.stringify(usePlugin ? pluginSettings() : { hooks: sessionHooks() }, null, 2)}\n`,
|
|
570
|
-
);
|
|
571
|
-
if (usePlugin && skipped.includes('.claude/settings.json')) {
|
|
572
|
-
// Moving to the plugin (BRK-159): --update takes out the hooks repos init wrote and adds the plugin's two keys.
|
|
573
|
-
const there = readTarget('.claude/settings.json');
|
|
574
|
-
const moved = withPluginSettings(there);
|
|
575
|
-
if (update && moved !== null && !sameJson(moved, there)) {
|
|
576
|
-
skipped.splice(skipped.indexOf('.claude/settings.json'), 1);
|
|
577
|
-
files.push({ path: '.claude/settings.json', content: moved, changed: true });
|
|
578
|
-
} else if (!hasPlugin(there))
|
|
579
|
-
notes.push(
|
|
580
|
-
`.claude/settings.json is already there${moved === null ? " and isn't JSON" : ''}: add breakaway's plugin to it (${JSON.stringify(pluginSettings())}), or a started agent won't have the tasks skill and its output won't show on its task.${update ? '' : ` npx breakaway repos init ${repo.slug} --update adds it.`}`,
|
|
581
|
-
);
|
|
582
|
-
} else if (skipped.includes('.claude/settings.json')) {
|
|
458
|
+
add('.claude/settings.json', `${JSON.stringify({ hooks: sessionHooks() }, null, 2)}\n`);
|
|
459
|
+
if (skipped.includes('.claude/settings.json')) {
|
|
583
460
|
const there = readTarget('.claude/settings.json');
|
|
584
461
|
const rewired = update ? rewireHooks(there) : there;
|
|
585
462
|
if (rewired !== there) {
|
|
@@ -594,19 +471,9 @@ export function initPlan({
|
|
|
594
471
|
);
|
|
595
472
|
}
|
|
596
473
|
if (readTarget('.claude/skills') === null) files.push({ path: '.claude/skills', link: '../.agents/skills' });
|
|
597
|
-
|
|
598
|
-
else if (update && readTarget(SKILL) !== null) {
|
|
599
|
-
// The plugin brings the tasks skill: the copy repos init wrote goes, and a skill of the repository's own stays.
|
|
600
|
-
if (owns(SKILL)) {
|
|
601
|
-
removals.push(SKILL);
|
|
602
|
-
if (String(readTarget('AGENTS.md') ?? '').includes(SKILL))
|
|
603
|
-
notes.push(
|
|
604
|
-
`AGENTS.md still names ${SKILL} and the session hooks in .claude/settings.json: the tasks skill and the hooks come from breakaway's plugin now (${PLUGIN}, turned on in .claude/settings.json), so say that there instead.`,
|
|
605
|
-
);
|
|
606
|
-
} else theirs.push(SKILL);
|
|
607
|
-
}
|
|
474
|
+
copy(SKILL, skillFor(read(SKILL), board, repo));
|
|
608
475
|
copy(PIPELINE_SKILL, skillFor(read(PIPELINE_SKILL), board, repo));
|
|
609
|
-
add('AGENTS.md', agentsMd(repo, board, configDir
|
|
476
|
+
add('AGENTS.md', agentsMd(repo, board, configDir));
|
|
610
477
|
if (!skipped.includes('AGENTS.md')) todo.push('AGENTS.md: add how to build in this repository');
|
|
611
478
|
|
|
612
479
|
const pkg = readTarget('package.json');
|
|
@@ -697,5 +564,5 @@ export function initPlan({
|
|
|
697
564
|
else if (recordThere === record) current.push(MANIFEST);
|
|
698
565
|
else if (update) files.push({ path: MANIFEST, content: record, changed: true });
|
|
699
566
|
else skipped.push(MANIFEST);
|
|
700
|
-
return { files, removals, skipped, current, notes, todo
|
|
567
|
+
return { files, removals, skipped, current, notes, todo };
|
|
701
568
|
}
|
package/src/install.js
CHANGED
|
@@ -158,7 +158,7 @@ export const secretName = (inst, key) => `${inst.secretsPrefix}${key}`;
|
|
|
158
158
|
export const docsLink = (inst, anchor) => (inst.docs ? `${inst.docs}${anchor ? `#${anchor}` : ''}` : null);
|
|
159
159
|
|
|
160
160
|
/** The routes `run_worker_first` sends to the Worker; everything else is the web app. */
|
|
161
|
-
export const WORKER_FIRST = ['/api/*', '/v1/*', '/github/*', '/
|
|
161
|
+
export const WORKER_FIRST = ['/api/*', '/v1/*', '/github/*', '/login', '/logout'];
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
164
|
* The Worker's wrangler config for an install. `local` is for `wrangler dev` (interop): no custom
|
package/scripts/plugin.mjs
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Writes the breakaway plugin's generated files (CLI-7, docs/specs/IDEA-25-claude-plugin.md): the `tasks` skill, made
|
|
4
|
-
* from .agents/skills/tasks/SKILL.md for any repository, the session hooks from sessionHooks() in src/init.js, and the
|
|
5
|
-
* licence. Unlike src/board-files.json they're committed, because the plugin is read from git; a test in
|
|
6
|
-
* scripts/tasks/plugin.test.js fails when one is behind its source. The rest of plugin/ is written by hand.
|
|
7
|
-
*/
|
|
8
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
-
import { SKILL } from '../src/prompt.js';
|
|
10
|
-
import { sessionHooks, skillFor } from '../src/init.js';
|
|
11
|
-
import { BREAKAWAY_REPO } from '../src/updates.js';
|
|
12
|
-
|
|
13
|
-
export const BOARD = BREAKAWAY_REPO;
|
|
14
|
-
export const PLUGIN_DIR = 'plugin/';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* The tasks skill as the plugin ships it. A plugin works in any checkout, so the skill names no breakaway rule and
|
|
18
|
-
* links nowhere a checkout may not have: the board's docs and the core point at GitHub, `AGENTS.md` is the checkout's
|
|
19
|
-
* own. Each change must find its text, so a skill reworded there fails here instead of shipping half changed.
|
|
20
|
-
*/
|
|
21
|
-
export function pluginSkill(text, board = BOARD) {
|
|
22
|
-
/** @type {[RegExp, string][]} */
|
|
23
|
-
const changes = [
|
|
24
|
-
[
|
|
25
|
-
/breakaway's work is on the board that tracks this repository/gu,
|
|
26
|
-
"This repository's work is on the board that tracks it",
|
|
27
|
-
],
|
|
28
|
-
[/breakaway's areas: [^.\n]+\./gu, "This repository's areas are in its `AGENTS.md`."],
|
|
29
|
-
[
|
|
30
|
-
/\[`prompts\/core\.md`\]\(\.\.\/\.\.\/\.\.\/prompts\/core\.md\)/gu,
|
|
31
|
-
`the core: \`tools/tasks/prompts/core.md\` in a repository \`repos init\` set up, and [on GitHub](https://github.com/${board}/blob/main/prompts/core.md)`,
|
|
32
|
-
],
|
|
33
|
-
[/\[`AGENTS\.md`\]\(\.\.\/\.\.\/\.\.\/AGENTS\.md\)/gu, '`AGENTS.md`'],
|
|
34
|
-
[
|
|
35
|
-
/Follow \[`prompts\/breakaway\.md`\]\(\.\.\/\.\.\/\.\.\/prompts\/breakaway\.md\), which starts with the core\./gu,
|
|
36
|
-
"Follow the repository's agent prompt, which `AGENTS.md` names and which starts with the core.",
|
|
37
|
-
],
|
|
38
|
-
];
|
|
39
|
-
let out = String(text);
|
|
40
|
-
for (const [pattern, replacement] of changes) {
|
|
41
|
-
if (!pattern.test(out))
|
|
42
|
-
throw new Error(`The tasks skill no longer has ${pattern}: update pluginSkill in scripts/plugin.mjs.`);
|
|
43
|
-
pattern.lastIndex = 0;
|
|
44
|
-
out = out.replace(pattern, replacement);
|
|
45
|
-
}
|
|
46
|
-
// The rest of the board's docs are on GitHub, as they are for a copy (skillFor).
|
|
47
|
-
return skillFor(out, board);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** The plugin's generated files, by their path from the repository's root. */
|
|
51
|
-
export function pluginFiles(read) {
|
|
52
|
-
return {
|
|
53
|
-
[`${PLUGIN_DIR}skills/tasks/SKILL.md`]: pluginSkill(read(SKILL)),
|
|
54
|
-
[`${PLUGIN_DIR}hooks/hooks.json`]: `${JSON.stringify({ hooks: sessionHooks() }, null, 2)}\n`,
|
|
55
|
-
[`${PLUGIN_DIR}LICENSE`]: read('LICENSE'),
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
60
|
-
const ROOT = new URL('../', import.meta.url);
|
|
61
|
-
const read = (/** @type {string} */ path) => readFileSync(new URL(path, ROOT), 'utf8');
|
|
62
|
-
for (const [path, content] of Object.entries(pluginFiles(read))) writeFileSync(new URL(path, ROOT), content);
|
|
63
|
-
console.error('Wrote the plugin’s skill, hooks, and licence in plugin/.');
|
|
64
|
-
}
|
package/scripts/tasks/mcp.js
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* npx breakaway mcp (docs/specs/IDEA-24-mcp-server.md, section 5; CLI-6): how a checkout connects an MCP client to the
|
|
3
|
-
* board's /mcp, and whether it answers. Pure apart from the `fetch` it's handed, so it's tested without a board.
|
|
4
|
-
*/
|
|
5
|
-
import { githubFromRemote, pickRepo } from './repo.js';
|
|
6
|
-
|
|
7
|
-
/** The MCP revision `--check` speaks: the one with an `initialize` handshake, which /mcp accepts (src/mcp.js, LEGACY). */
|
|
8
|
-
export const CHECK_PROTOCOL = '2025-11-25';
|
|
9
|
-
/** The server's name in a client's config. */
|
|
10
|
-
export const SERVER = 'breakaway';
|
|
11
|
-
|
|
12
|
-
const AGENT = /^[\w.@:/-]{1,64}$/u;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The agent name an MCP connection claims as: --as or BREAKAWAY_AGENT (`named`), else `claude-<branch>` for the
|
|
16
|
-
* checkout's branch, else `fallback` (the CLI's own default). A branch's `/` and anything a name can't hold become `-`.
|
|
17
|
-
* @param {{ named?: string | null, branch?: string | null, fallback: string }} options
|
|
18
|
-
*/
|
|
19
|
-
export function mcpAgent({ named, branch, fallback }) {
|
|
20
|
-
if (named) return named;
|
|
21
|
-
const b = String(branch ?? '').trim();
|
|
22
|
-
if (!b || b === 'HEAD') return fallback;
|
|
23
|
-
const name = (/^claude[-/]/u.test(b) ? b : `claude-${b}`).replace(/[^\w.@:-]+/gu, '-').slice(0, 64);
|
|
24
|
-
return AGENT.test(name) ? name : fallback;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* The `claude mcp add` line and the `.mcp.json` entry for a checkout. The token is always the environment variable
|
|
29
|
-
* `tokenVar`, never its value: the shell fills it in for the line, and Claude Code for `.mcp.json`. Without `repo` (a
|
|
30
|
-
* checkout the board doesn't track) there's no X-Breakaway-Repo, and the repository's tools say what's missing.
|
|
31
|
-
* @param {{ url: string, agent: string, repo?: string | null, tokenVar: string }} options
|
|
32
|
-
* @returns {{ endpoint: string, command: string, json: { mcpServers: Record<string, any> } }}
|
|
33
|
-
*/
|
|
34
|
-
export function mcpConfig({ url, agent, repo = null, tokenVar }) {
|
|
35
|
-
const endpoint = `${String(url).replace(/\/+$/u, '')}/mcp`;
|
|
36
|
-
const headers = {
|
|
37
|
-
Authorization: `Bearer \${${tokenVar}}`,
|
|
38
|
-
'X-Breakaway-Agent': agent,
|
|
39
|
-
...(repo ? { 'X-Breakaway-Repo': repo } : {}),
|
|
40
|
-
};
|
|
41
|
-
const command = [
|
|
42
|
-
`claude mcp add --transport http ${SERVER} ${endpoint}`,
|
|
43
|
-
` --header "Authorization: Bearer $${tokenVar}"`,
|
|
44
|
-
` --header "X-Breakaway-Agent: ${agent}"`,
|
|
45
|
-
...(repo ? [` --header "X-Breakaway-Repo: ${repo}"`] : []),
|
|
46
|
-
].join(' \\\n');
|
|
47
|
-
return { endpoint, command, json: { mcpServers: { [SERVER]: { type: 'http', url: endpoint, headers } } } };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* `--headers`: the headers Claude Code sends to /mcp, for the plugin's `headersHelper` (CLI-9,
|
|
52
|
-
* docs/specs/IDEA-25-claude-plugin.md, section 4). Claude Code reads them as JSON from standard output, so the token is
|
|
53
|
-
* its value here, and only there. Outside a repository the board tracks there's only Authorization, and the repository's
|
|
54
|
-
* tools say what's missing; without a token there's none, so a session's proxy can add it.
|
|
55
|
-
* @param {{ token?: string | null, agent: string, repo?: string | null }} options
|
|
56
|
-
* @returns {Record<string, string>}
|
|
57
|
-
*/
|
|
58
|
-
export function mcpHeaders({ token = null, agent, repo = null }) {
|
|
59
|
-
return {
|
|
60
|
-
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
61
|
-
...(repo ? { 'X-Breakaway-Agent': agent, 'X-Breakaway-Repo': repo } : {}),
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* The repository `--headers` names: the board's slug for the checkout when the board says which (`GET /api/repos`), and
|
|
67
|
-
* null when it tracks none of them. A plugin's headersHelper runs without the plugin's settings, so with only the
|
|
68
|
-
* plugin set up there's no token to ask with: then it's the checkout's GitHub `owner/name`, which /mcp matches against
|
|
69
|
-
* its repositories itself (src/mcp.js). `named` is --repo or BREAKAWAY_REPO. Never throws: Claude Code is waiting.
|
|
70
|
-
* @param {{ base?: string | null, token?: string | null, named?: string | null, remote?: string | null, fetch: typeof globalThis.fetch }} options
|
|
71
|
-
* @returns {Promise<string | null>}
|
|
72
|
-
*/
|
|
73
|
-
export async function headersRepo({ base = null, token = null, named = null, remote = null, fetch }) {
|
|
74
|
-
let registry = null;
|
|
75
|
-
if (base)
|
|
76
|
-
try {
|
|
77
|
-
const res = await fetch(`${String(base).replace(/\/+$/u, '')}/api/repos`, {
|
|
78
|
-
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
79
|
-
signal: AbortSignal.timeout(5000),
|
|
80
|
-
});
|
|
81
|
-
if (res.ok) registry = await res.json();
|
|
82
|
-
} catch {
|
|
83
|
-
/* the board can't be reached: /mcp will say so itself */
|
|
84
|
-
}
|
|
85
|
-
if (registry) {
|
|
86
|
-
try {
|
|
87
|
-
return pickRepo({ named, remote, registry });
|
|
88
|
-
} catch {
|
|
89
|
-
return null; // --repo names a repository the board doesn't have
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
if (named) return String(named).trim().toLowerCase();
|
|
93
|
-
return githubFromRemote(remote)?.toLowerCase() ?? null;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* What `npx breakaway mcp` prints: the line, the `.mcp.json` entry, and what to check.
|
|
98
|
-
* @param {{ endpoint: string, command: string, json: object }} config
|
|
99
|
-
* @param {{ repo?: string | null, tokenVar: string }} options
|
|
100
|
-
*/
|
|
101
|
-
export function mcpLines(config, { repo = null, tokenVar }) {
|
|
102
|
-
return [
|
|
103
|
-
`Connect Claude Code to the board's MCP server at ${config.endpoint}:`,
|
|
104
|
-
'',
|
|
105
|
-
config.command,
|
|
106
|
-
'',
|
|
107
|
-
`Run it where ${tokenVar} is set, then type /mcp in Claude Code. Or put this in the checkout's .mcp.json, which`,
|
|
108
|
-
`Claude Code fills in from ${tokenVar} when it starts:`,
|
|
109
|
-
'',
|
|
110
|
-
JSON.stringify(config.json, null, 2),
|
|
111
|
-
'',
|
|
112
|
-
...(repo
|
|
113
|
-
? []
|
|
114
|
-
: [
|
|
115
|
-
'This checkout isn’t a repository the board tracks, so there’s no X-Breakaway-Repo header: the tools that work',
|
|
116
|
-
'in a repository will ask for one. Run this in a tracked checkout, or name one with --repo <slug>.',
|
|
117
|
-
'',
|
|
118
|
-
]),
|
|
119
|
-
`The token is the board's full token: give it only to a client you run. npx breakaway mcp --check tests the connection.`,
|
|
120
|
-
];
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* `--check`: an `initialize` and a `tools/list` against /mcp with the same headers the config sends, and what came
|
|
125
|
-
* back. `ok` is false with the board's own error when either fails.
|
|
126
|
-
* @param {{ endpoint: string, token?: string | null, agent: string, repo?: string | null, fetch: typeof globalThis.fetch }} options
|
|
127
|
-
* @returns {Promise<{ ok: boolean, lines: string[] }>}
|
|
128
|
-
*/
|
|
129
|
-
export async function checkMcp({ endpoint, token = null, agent, repo = null, fetch }) {
|
|
130
|
-
const headers = {
|
|
131
|
-
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
132
|
-
'Content-Type': 'application/json',
|
|
133
|
-
Accept: 'application/json, text/event-stream',
|
|
134
|
-
'X-Breakaway-Agent': agent,
|
|
135
|
-
...(repo ? { 'X-Breakaway-Repo': repo } : {}),
|
|
136
|
-
};
|
|
137
|
-
const rpc = async (id, method, params, version) => {
|
|
138
|
-
let res;
|
|
139
|
-
try {
|
|
140
|
-
res = await fetch(endpoint, {
|
|
141
|
-
method: 'POST',
|
|
142
|
-
headers: { ...headers, ...(version ? { 'MCP-Protocol-Version': version } : {}) },
|
|
143
|
-
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
|
144
|
-
});
|
|
145
|
-
} catch (error) {
|
|
146
|
-
return { error: `can't reach ${endpoint} (${reasonOf(error)})` };
|
|
147
|
-
}
|
|
148
|
-
const data = await res.json().catch(() => null);
|
|
149
|
-
// An install from before /mcp answers it with the web app, a 404, or a 405: anything but JSON-RPC.
|
|
150
|
-
if (res.status !== 401 && (res.status === 404 || res.status === 405 || (res.ok && !data?.jsonrpc)))
|
|
151
|
-
return {
|
|
152
|
-
error: `${endpoint} answered ${res.status}: this install has no MCP server yet. Update and deploy it (docs/tasks.md#updates).`,
|
|
153
|
-
};
|
|
154
|
-
if (res.status === 401)
|
|
155
|
-
return {
|
|
156
|
-
error: `${endpoint} refused the token (401)${data?.error ? `: ${data.error}` : ''}${token ? '' : '. No token is set'}`,
|
|
157
|
-
};
|
|
158
|
-
if (data?.error)
|
|
159
|
-
return { error: `${method} failed: ${data.error.message ?? data.error}${res.ok ? '' : ` (HTTP ${res.status})`}` };
|
|
160
|
-
if (!res.ok || !data?.result) return { error: `${method} failed: HTTP ${res.status}` };
|
|
161
|
-
return { result: data.result };
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const init = await rpc(1, 'initialize', {
|
|
165
|
-
protocolVersion: CHECK_PROTOCOL,
|
|
166
|
-
capabilities: {},
|
|
167
|
-
clientInfo: { name: 'breakaway-cli', version: '1' },
|
|
168
|
-
});
|
|
169
|
-
if (init.error) return { ok: false, lines: [`The board's MCP server didn't answer: ${init.error}`] };
|
|
170
|
-
const listed = await rpc(2, 'tools/list', {}, init.result.protocolVersion ?? CHECK_PROTOCOL);
|
|
171
|
-
if (listed.error) return { ok: false, lines: [`The board's MCP server didn't answer: ${listed.error}`] };
|
|
172
|
-
const server = init.result.serverInfo ?? {};
|
|
173
|
-
const tools = (listed.result.tools ?? []).map((t) => t.name);
|
|
174
|
-
return {
|
|
175
|
-
ok: true,
|
|
176
|
-
lines: [
|
|
177
|
-
`The board's MCP server answers at ${endpoint}: ${server.name ?? SERVER}${server.version ? ` ${server.version}` : ''}, MCP ${init.result.protocolVersion}.`,
|
|
178
|
-
`${tools.length} tools: ${tools.join(', ') || 'none'}.`,
|
|
179
|
-
`As ${agent}${repo ? `, in ${repo}` : ', in no repository (set X-Breakaway-Repo, or run it in a tracked checkout)'}.`,
|
|
180
|
-
],
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Why a fetch failed: the innermost cause, like ECONNREFUSED. */
|
|
185
|
-
function reasonOf(error) {
|
|
186
|
-
let root = error;
|
|
187
|
-
while (root?.cause) root = root.cause;
|
|
188
|
-
return typeof root?.code === 'string' ? root.code : (root?.message ?? String(error));
|
|
189
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hands the plugin's settings to the session's Bash commands (CLI-8, docs/specs/IDEA-25-claude-plugin.md, section 2).
|
|
3
|
-
* Claude Code gives a plugin's options to its hooks as CLAUDE_PLUGIN_OPTION_<option>, but not to the commands its
|
|
4
|
-
* skills run, so `/breakaway:next` would find no board. A SessionStart hook may append `export` lines to
|
|
5
|
-
* CLAUDE_ENV_FILE, which Claude Code applies to every later Bash command, so the plugin's SessionStart hook passes
|
|
6
|
-
* them on under the same names: the CLI still reads them after its own settings (scripts/tasks/settings.js).
|
|
7
|
-
*/
|
|
8
|
-
import { appendFileSync } from 'node:fs';
|
|
9
|
-
import { PLUGIN_OPTIONS } from './settings.js';
|
|
10
|
-
|
|
11
|
-
/** `value` quoted for a POSIX shell. */
|
|
12
|
-
const quote = (value) => `'${String(value).replace(/'/gu, `'\\''`)}'`;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The `export` lines for the plugin's options that are set, or '' outside a plugin's hook (no CLAUDE_PLUGIN_ROOT)
|
|
16
|
-
* or when none is.
|
|
17
|
-
* @param {Record<string, string | undefined>} env
|
|
18
|
-
*/
|
|
19
|
-
export function pluginEnvLines(env) {
|
|
20
|
-
if (!env.CLAUDE_PLUGIN_ROOT) return '';
|
|
21
|
-
return Object.values(PLUGIN_OPTIONS)
|
|
22
|
-
.filter((name) => env[name])
|
|
23
|
-
.map((name) => `export ${name}=${quote(env[name])}\n`)
|
|
24
|
-
.join('');
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Appends them to CLAUDE_ENV_FILE when Claude Code gave one (only SessionStart does). Never throws: hooks stay quiet. */
|
|
28
|
-
export function passPluginEnv(env = process.env, append = appendFileSync) {
|
|
29
|
-
const lines = env.CLAUDE_ENV_FILE ? pluginEnvLines(env) : '';
|
|
30
|
-
if (!lines) return false;
|
|
31
|
-
try {
|
|
32
|
-
append(env.CLAUDE_ENV_FILE, lines, { mode: 0o600 });
|
|
33
|
-
return true;
|
|
34
|
-
} catch {
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The session hooks when breakaway's plugin runs them (BRK-159, docs/specs/IDEA-25-claude-plugin.md, section 7): a
|
|
3
|
-
* checkout repos init set up before the plugin, or with --copies, runs the same hooks from its .claude/settings.json,
|
|
4
|
-
* so a person who installed the plugin would post each entry twice. Claude Code sets CLAUDE_PLUGIN_ROOT for a plugin's
|
|
5
|
-
* hooks, and then the plugin's hooks step aside for the checkout's.
|
|
6
|
-
*/
|
|
7
|
-
import { readFileSync } from 'node:fs';
|
|
8
|
-
import { join } from 'node:path';
|
|
9
|
-
|
|
10
|
-
/** A session hook command repos init writes: through npx, any version, or an old copy's script. */
|
|
11
|
-
const BOARD_HOOK =
|
|
12
|
-
/\bbreakaway(?:@[^\s"]+)? hook (?:session|wait)\b|scripts\/tasks\/(?:session-hook|message-wait)\.mjs/u;
|
|
13
|
-
|
|
14
|
-
/** A file's text, or null when it can't be read. */
|
|
15
|
-
function readOptional(path) {
|
|
16
|
-
try {
|
|
17
|
-
return readFileSync(path, 'utf8');
|
|
18
|
-
} catch {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Whether this hook is the plugin's and the checkout's settings already run the board's hooks, so this one does
|
|
25
|
-
* nothing. Outside the plugin it's always false: the checkout's own hooks always run.
|
|
26
|
-
*/
|
|
27
|
-
export function checkoutRunsHooks(root, env = process.env, read = readOptional) {
|
|
28
|
-
if (!env.CLAUDE_PLUGIN_ROOT) return false;
|
|
29
|
-
return ['settings.json', 'settings.local.json'].some((name) =>
|
|
30
|
-
BOARD_HOOK.test(read(join(root, '.claude', name)) ?? ''),
|
|
31
|
-
);
|
|
32
|
-
}
|