@ajaykumarnpm/talea 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to this project are recorded here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the versions follow
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.5.0] - 2026-09-22
8
+
9
+ ### Added
10
+ - run commands from outside a workspace
11
+
12
+ ## [0.4.0] - 2026-09-22
13
+
14
+ ### Added
15
+ - talea skill, and the CLI in the design system's palette
16
+
17
+ ### Fixed
18
+ - **test**: skill.test.js asserted LF against a CRLF checkout
19
+ - **test**: init.test.js depended on the author's own GitHub auth
20
+ - create group folders only when cloning
21
+
22
+ ### Documentation
23
+ - the site and the manual, at talea-run.web.app
24
+
7
25
  ## [0.3.0] - 2026-09-21
8
26
 
9
27
  ### Added
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  **One folder structure for every machine you work on.**
4
4
 
5
+ [talea-run.web.app](https://talea-run.web.app) — the site, and [the manual](https://talea-run.web.app/docs/).
6
+
5
7
  You have a laptop, a desktop, and a work machine. On each one, the repo you want
6
8
  is either missing or somewhere you have to go and find. `talea` fixes that: one
7
9
  catalogue of your GitHub repos, one tree, and a command that makes any machine
@@ -44,6 +46,9 @@ talea manifest push # publish the catalogue so the next machine can rea
44
46
  repos, every org you belong to, and anything shared with you directly. It then
45
47
  asks what this machine should keep and fills the tree.
46
48
 
49
+ Every command works from anywhere after that. Outside the workspace it uses
50
+ the one `init` made; with more than one on the machine, it asks which.
51
+
47
52
  ## Every machine after that
48
53
 
49
54
  ```sh
@@ -64,6 +69,21 @@ talea select # reopen the checklist and change the whole list
64
69
  cd $(talea where eklavya)
65
70
  ```
66
71
 
72
+ ## Let your coding agent do it
73
+
74
+ ```sh
75
+ talea skill install
76
+ ```
77
+
78
+ Installs a skill into Claude Code at **user scope**, so every project you open
79
+ has it. After that `"where is eklavya?"` and `"my repos are scattered, tidy them
80
+ up"` reach the right command — with the guardrails attached: an adopt is always
81
+ shown as a dry run first, `--loose` is never taken on your behalf, and a removal
82
+ is reported as *taken off the list* rather than as a delete.
83
+
84
+ `talea skill` says whether it is installed, `talea skill uninstall` takes it back
85
+ out, and it refuses to overwrite a skill called `talea` that talea did not write.
86
+
67
87
  ---
68
88
 
69
89
  ## The repos you already have
@@ -154,6 +174,7 @@ Treat the id like a bookmark you would not paste into a public channel.
154
174
  | `talea tree` | the folder tree on disk |
155
175
  | `talea exec -- <cmd>` | run one command in every repo |
156
176
  | `talea manifest push/pull` | move the catalogue between machines |
177
+ | `talea skill` | install the skill that lets your coding agent drive talea |
157
178
  | `talea doctor` | check this machine can do the work |
158
179
  | `talea upgrade` | update the CLI itself |
159
180
 
@@ -195,6 +216,14 @@ node bin/talea.js --help
195
216
 
196
217
  Tests run on macOS, Linux and Windows across Node 20, 22 and 24 on every push.
197
218
 
219
+ The website lives in `web/` and is its own thing — Astro, deployed to Firebase
220
+ Hosting on a push to `main` that touches it. `web/CLAUDE.md` is how to work on
221
+ it.
222
+
223
+ ```sh
224
+ cd web && npm install && npm run dev
225
+ ```
226
+
198
227
  ## Licence
199
228
 
200
229
  MIT.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ajaykumarnpm/talea",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "One folder structure for every machine. Clone, adopt and sync your GitHub repos from a manifest you own.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "bin",
17
17
  "src",
18
18
  "manifest",
19
+ "skills",
19
20
  "templates",
20
21
  "README.md",
21
22
  "CHANGELOG.md"
@@ -0,0 +1,176 @@
1
+ ---
2
+ name: talea
3
+ description: "Operate talea, the CLI that gives every machine the same folder structure for its GitHub repos. Use when the user asks where a repo is, to clone or sync or pull their repos, to tidy or reorganise a workspace, to adopt checkouts that are in the wrong place, to change what this machine keeps, or to move their repo catalogue to another machine. Also use before running a command in several repos at once. Do not use for ordinary git work inside one repo that is already checked out."
4
+ ---
5
+
6
+ <!-- talea-skill: installed by `talea skill install`; edits are overwritten -->
7
+
8
+ # talea
9
+
10
+ `talea` keeps one folder structure across every machine a developer works on.
11
+ It holds a catalogue of their GitHub repos, moves checkouts they already have
12
+ into place, clones what is missing, and fast-forwards the rest.
13
+
14
+ Two rules make it safe to hand to an agent: it never pushes, and it never
15
+ deletes. Everything below leans on that.
16
+
17
+ ## Find the binary first
18
+
19
+ ```bash
20
+ command -v talea
21
+ ```
22
+
23
+ That prints the path, or nothing. It is installed with
24
+ `npm install -g @ajaykumarnpm/talea` — the package is scoped, the command is
25
+ not. If it prints nothing, fall back to `npx -y @ajaykumarnpm/talea <command>`,
26
+ which downloads on first use. If that fails too, say talea is not installed and
27
+ give the one install command rather than guessing at a path.
28
+
29
+ Shell variables do not survive between tool calls, so write the resolved path
30
+ into every later command rather than setting `T=` and hoping.
31
+
32
+ Everything below writes `talea` for readability. Substitute whatever the line
33
+ above resolved to.
34
+
35
+ ## Where is this repo?
36
+
37
+ This is the command to reach for most, and the reason the tool exists.
38
+
39
+ ```bash
40
+ talea where eklavya # the absolute path, one line, nothing else
41
+ cd "$(talea where eklavya)" # what it is actually for
42
+ talea where # the workspace root
43
+ ```
44
+
45
+ It exits **non-zero** on an unknown name and writes every diagnostic to stderr,
46
+ so `cd "$(talea where typo)"` fails instead of landing in the home directory.
47
+ Never `cd` to a path it did not print.
48
+
49
+ Outside a workspace it falls back to the ones this machine has. With several
50
+ and no terminal — which is how you run it — it lists them and exits non-zero.
51
+ `cd` into the workspace the user means rather than guessing from the list.
52
+
53
+ Two owners can own a repo of the same name. When that happens it says so and
54
+ exits non-zero — pass `owner/name` rather than picking one.
55
+
56
+ ## Read before you write
57
+
58
+ None of these change anything on disk. Run one before proposing work.
59
+
60
+ | Command | Answers |
61
+ |---|---|
62
+ | `talea status` | branch, clean or dirty, ahead or behind, for every repo this machine keeps |
63
+ | `talea list` | the catalogue — every repo, its group, its folder |
64
+ | `talea tree` | the folder tree as it actually is on disk |
65
+ | `talea doctor` | can this machine do the work: git, SSH, GitHub auth, the workspace |
66
+ | `talea adopt` | what a move *would* do. It changes nothing without `--apply` |
67
+
68
+ `talea status` is the one to run before suggesting a sync — a dirty repo is
69
+ skipped, and saying so up front is better than reporting it afterwards.
70
+
71
+ ## Bringing a machine into line
72
+
73
+ ```bash
74
+ talea sync # clone what is missing, fast-forward what is there
75
+ talea clone # clone only — never fetches or merges
76
+ talea sync -g NonStop # one group
77
+ talea sync -r eklavya # one repo
78
+ ```
79
+
80
+ `sync` is safe to run unattended. It fast-forwards **the branch you are on** and
81
+ leaves anything else alone with a line saying why. It never switches branches,
82
+ never merges a divergence and never pushes.
83
+
84
+ Every bulk command exits non-zero if **any** repo failed, so check the exit
85
+ code — the per-repo errors are printed but the run keeps going.
86
+
87
+ ## Adoption: the one that moves things
88
+
89
+ A repo the developer already has, in the wrong folder, is **moved** into place
90
+ rather than re-cloned. A move keeps branches, stashes, the reflog and
91
+ uncommitted work; a re-clone throws all of it away.
92
+
93
+ ```bash
94
+ talea adopt # show what would move, change nothing
95
+ talea adopt --from ~/Desktop # look there too; the folder is remembered
96
+ talea adopt --apply # do it
97
+ ```
98
+
99
+ **Always run it without `--apply` first and show the developer the plan.** A
100
+ move relocates directories they may have open in an editor, a terminal or a
101
+ long-running process.
102
+
103
+ Matching is on the **git remote URL**, not the folder name. When only the name
104
+ matches — a fork, a mirror, an SDK cache — it is listed and left alone. `--loose`
105
+ or `-r <repo>` includes it. Do not reach for `--loose` on the developer's behalf:
106
+ the guard exists because an FVM Flutter SDK cache reports `origin` as
107
+ `flutter/flutter` and name-matches a personal fork, and adopting it breaks every
108
+ Flutter project on the machine.
109
+
110
+ A second copy of the same repo is **parked** in `.talea-duplicates/`, not
111
+ deleted. Say so when it happens — clearing that folder is the developer's call,
112
+ and nothing in talea will do it for them.
113
+
114
+ ## What this machine keeps
115
+
116
+ ```bash
117
+ talea add some-repo # keep one more here, and clone it now
118
+ talea rm some-repo # stop keeping it — the checkout stays exactly where it is
119
+ talea select # reopen the whole checklist, interactively
120
+ ```
121
+
122
+ `talea select` needs a terminal. In a non-interactive session it prints a
123
+ summary instead of hanging, so prefer `add` and `rm` when acting on the
124
+ developer's behalf and leave `select` as something to suggest they run.
125
+
126
+ `rm` does **not** delete the checkout. Say that plainly when you run it, or it
127
+ reads like data loss.
128
+
129
+ ## Running one command everywhere
130
+
131
+ ```bash
132
+ talea exec -- git status --short
133
+ talea exec -g NonStop -- npm test
134
+ ```
135
+
136
+ Everything after `--` runs in each repo. Treat it as you would any command run
137
+ in N repositories at once: read-only commands freely, anything that writes only
138
+ when the developer asked for it by name.
139
+
140
+ ## Moving the catalogue between machines
141
+
142
+ ```bash
143
+ talea manifest push # publish it; prints a gist id
144
+ talea manifest pull <gist-id> # on the next machine
145
+ ```
146
+
147
+ The gist is **private**, but it still carries the names of private
148
+ repositories. Treat the id as something not to paste into a public channel, and
149
+ do not print it into a message that is going somewhere shared.
150
+
151
+ The split that matters: the **catalogue** travels, the **selection** does not.
152
+ Pulling it onto a new laptop hands over the full list to choose from, never the
153
+ last machine's choices. So `manifest pull` alone changes nothing about what is
154
+ cloned — `talea init` or `talea select` after it is what fills the tree.
155
+
156
+ ## Narrowing any run
157
+
158
+ Every command takes `-g <group>` and `-r <repo>`, both repeatable, and `--help`
159
+ for its own examples. An unknown group or repo name **exits non-zero** rather
160
+ than quietly doing nothing, so a typo is loud.
161
+
162
+ ## Rules
163
+
164
+ - **Never `--apply` an adopt the developer has not seen the dry run of.**
165
+ - **Never `--loose`** unless they have looked at the name-only matches and said
166
+ which one they want.
167
+ - Report a move as *moved*, a removal as *taken off the list*, and a duplicate
168
+ as *parked*. Those are three different things and the words are the whole
169
+ safety story.
170
+ - Do not add `ignore: true` to a catalogue entry to work around a conflict
171
+ without saying so — it means another tool owns that checkout, and every talea
172
+ command will skip it from then on.
173
+ - talea never pushes. If a repo is ahead of its remote, report it and stop;
174
+ pushing is the developer's call and not talea's job.
175
+ - A bulk command that exits non-zero has a failed repo in it. Read the output
176
+ for the failures rather than reporting the run as done.
package/src/cli.js CHANGED
@@ -20,6 +20,7 @@ import * as manifest from './commands/manifest.js';
20
20
  import * as doctor from './commands/doctor.js';
21
21
  import * as exec from './commands/exec.js';
22
22
  import * as upgrade from './commands/upgrade.js';
23
+ import * as skill from './commands/skill.js';
23
24
  import { notifyIfOutdatedAsync } from './update.js';
24
25
 
25
26
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -41,6 +42,7 @@ const COMMANDS = {
41
42
  manifest,
42
43
  doctor,
43
44
  exec,
45
+ skill,
44
46
  upgrade,
45
47
  };
46
48
 
@@ -59,6 +61,7 @@ const ALIASES = {
59
61
  cd: 'where',
60
62
  path: 'where',
61
63
  run: 'exec',
64
+ skills: 'skill',
62
65
  remove: 'rm',
63
66
  'self-update': 'upgrade',
64
67
  };
@@ -115,6 +118,7 @@ ${c.bold('Commands')}
115
118
  ${c.cyan('exec')} run one command in every repo
116
119
  ${c.cyan('manifest')} push or pull the catalogue through a private gist
117
120
  ${c.cyan('doctor')} check git, SSH, GitHub auth and workspace health
121
+ ${c.cyan('skill')} install the skill that lets your coding agent drive talea
118
122
  ${c.cyan('upgrade')} update the CLI itself
119
123
 
120
124
  ${c.bold('A new machine')}
@@ -130,6 +134,9 @@ ${c.bold('Every day')}
130
134
  talea sync ${c.dim('# clone the new, fast-forward the rest')}
131
135
  talea status --drift ${c.dim('# what is not where I left it')}
132
136
 
137
+ ${c.bold('With an agent')}
138
+ talea skill install ${c.dim('# then ask Claude Code "where is eklavya?"')}
139
+
133
140
  ${c.bold('Common options')}
134
141
  -g, --group <names> restrict to groups, e.g. -g nonstopio
135
142
  -r, --repo <names> restrict to repos, e.g. -r eklavya
@@ -54,7 +54,7 @@ export function resolve(manifest, name) {
54
54
  }
55
55
 
56
56
  export async function run(opts, positionals = []) {
57
- const { root, manifest, state } = requireWorkspace();
57
+ const { root, manifest, state } = await requireWorkspace();
58
58
  requireCatalogue(manifest);
59
59
 
60
60
  if (!positionals.length) {
@@ -322,7 +322,7 @@ export function parseFromPaths(from) {
322
322
  }
323
323
 
324
324
  export async function run(opts) {
325
- const { root, manifest, state } = requireWorkspace();
325
+ const { root, manifest, state } = await requireWorkspace();
326
326
 
327
327
  if (opts['fix-paths']) {
328
328
  heading('Repairing paths for repos already adopted');
@@ -200,7 +200,7 @@ export function writeDocs(manifest, root, repos) {
200
200
  }
201
201
 
202
202
  export async function run(opts) {
203
- const { root, manifest, state } = requireWorkspace();
203
+ const { root, manifest, state } = await requireWorkspace();
204
204
  requireCatalogue(manifest);
205
205
 
206
206
  const protocol = opts.protocol ?? state.protocol ?? 'ssh';
@@ -46,7 +46,7 @@ export async function run(opts, positionals) {
46
46
  process.exit(1);
47
47
  }
48
48
 
49
- const { root, manifest, state } = requireWorkspace();
49
+ const { root, manifest, state } = await requireWorkspace();
50
50
  const pool = opts.all ? manifest.repos : machineRepos(manifest, state);
51
51
  const entries = clonedOnly(withPaths(manifest, root, selectRepos(manifest, opts, pool)));
52
52
 
@@ -7,17 +7,15 @@ import {
7
7
  STATE_FILE,
8
8
  expandHome,
9
9
  findWorkspace,
10
- groupDir,
11
10
  loadManifest,
12
- loadState,
13
11
  repoGroup,
14
12
  saveState,
15
13
  } from '../config.js';
16
14
  import { samePath } from '../adopt.js';
17
- import { machineRepos } from '../workspace.js';
18
15
  import { c, context, fail, heading, info, ok, plain, skip, warn } from '../log.js';
19
16
  import { run as discover } from './discover.js';
20
17
  import { run as sync } from './sync.js';
18
+ import { rememberWorkspace } from '../workspace.js';
21
19
 
22
20
  export const help = `
23
21
  ${c.bold('talea init')} — set this machine up
@@ -108,6 +106,8 @@ export async function run(opts, positionals = []) {
108
106
  } else {
109
107
  skip(`${STATE_FILE} already here, leaving it alone`);
110
108
  }
109
+ // So `talea sync` run from outside any workspace knows this one exists.
110
+ rememberWorkspace(target);
111
111
 
112
112
  let manifest = loadManifest(target);
113
113
 
@@ -129,13 +129,13 @@ export async function run(opts, positionals = []) {
129
129
  ['groups', `${groups.length}`],
130
130
  ]);
131
131
 
132
- // The group folders for the repos this machine will actually holdnot one
133
- // per owner in the catalogue. A collaborator repo you never clone should not
134
- // leave an empty folder in the tree forever.
135
- const mine = machineRepos(manifest, loadState(target));
136
- for (const g of new Set(mine.map((r) => repoGroup(r)))) {
137
- mkdirSync(path.join(target, groupDir(manifest, g)), { recursive: true });
138
- }
132
+ // No group folders are created here. `init` runs before the pickerthe
133
+ // checklist lives in `sync` so anything made at this point is made from the
134
+ // catalogue's defaults, which is every owner the discovery found. Picking
135
+ // four repos out of 228 then left 18 empty owner folders in the tree, and an
136
+ // empty folder is indistinguishable from a checkout somebody deleted.
137
+ // `cloneMissing` creates the parent of each repo it is about to clone, so the
138
+ // only folders that ever appear are the ones with something in them.
139
139
 
140
140
  if (opts.clone === false) {
141
141
  plain(`\n${c.dim('Workspace ready. Run `talea sync` when you want the repos.')}`);
@@ -48,7 +48,7 @@ export function changes(before, after) {
48
48
  }
49
49
 
50
50
  export async function run(opts) {
51
- const { root, manifest, state } = requireWorkspace();
51
+ const { root, manifest, state } = await requireWorkspace();
52
52
  requireCatalogue(manifest);
53
53
 
54
54
  // -g/-r narrow a run; they cannot narrow a decision about the whole machine.
@@ -0,0 +1,142 @@
1
+ // `talea skill` — put the agent-facing skill in the user's Claude Code.
2
+ //
3
+ // The skill is a single Markdown file describing how to drive this CLI safely:
4
+ // which commands read, which move things, and which two flags must never be
5
+ // reached for on somebody's behalf. Installing it at **user scope**
6
+ // (`~/.claude/skills/`) rather than in a repo is the point — the developer has
7
+ // one workspace across every project, so the agent needs the same instructions
8
+ // in all of them.
9
+
10
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ import { c, fail, heading, info, ok, plain, warn } from '../log.js';
16
+
17
+ const here = path.dirname(fileURLToPath(import.meta.url));
18
+
19
+ /** The copy that ships in the package. */
20
+ export const source = path.join(here, '..', '..', 'skills', 'talea', 'SKILL.md');
21
+
22
+ /**
23
+ * Claude Code reads user-scope skills from `~/.claude/skills/<name>/SKILL.md`.
24
+ * `CLAUDE_CONFIG_DIR` is how somebody moves that directory, and honouring it is
25
+ * the difference between installing the skill and installing it somewhere
26
+ * nothing will ever read it.
27
+ */
28
+ export const target = () =>
29
+ path.join(
30
+ process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'),
31
+ 'skills',
32
+ 'talea',
33
+ 'SKILL.md',
34
+ );
35
+
36
+ /**
37
+ * The marker that says a file at that path is ours.
38
+ *
39
+ * Somebody may already have their own skill called `talea`. Overwriting it
40
+ * would destroy work this tool has no claim on, so the only file `install`
41
+ * will replace is one carrying this line, and the only file `uninstall` will
42
+ * delete is the same.
43
+ */
44
+ const MARK = '<!-- talea-skill:';
45
+
46
+ export const isOurs = (file) => {
47
+ try {
48
+ return readFileSync(file, 'utf8').includes(MARK);
49
+ } catch {
50
+ return false;
51
+ }
52
+ };
53
+
54
+ export const help = `
55
+ ${c.bold('talea skill')} — teach your coding agent to drive talea
56
+
57
+ ${c.dim('talea skill')} is it installed, and where
58
+ ${c.dim('talea skill install')} copy it into ~/.claude/skills/talea/
59
+ ${c.dim('talea skill uninstall')} take it back out
60
+
61
+ Installs at ${c.bold('user scope')}, so every project you open gets it — your workspace
62
+ spans all of them, and the agent needs the same instructions in each.
63
+
64
+ The skill is what turns ${c.dim('"where is eklavya?"')} and ${c.dim('"tidy up my repos"')} into the
65
+ right talea command, with the guardrails attached: an adopt is always dry-run
66
+ first, ${c.dim('--loose')} is never taken on your behalf, and nothing is ever deleted.
67
+
68
+ It refuses to overwrite a skill called ${c.dim('talea')} that this tool did not write.
69
+ Move yours first if you have one.
70
+ `;
71
+
72
+ export function run(opts, positionals = []) {
73
+ const action = positionals[0] ?? 'status';
74
+ const dest = target();
75
+
76
+ if (action === 'status') return status(dest);
77
+ if (action === 'install') return install(dest);
78
+ if (action === 'uninstall' || action === 'remove') return uninstall(dest);
79
+
80
+ fail(`Unknown: talea skill ${action}`);
81
+ plain(`\n Try ${c.dim('talea skill install')}, ${c.dim('talea skill uninstall')}, or ${c.dim('talea skill')} on its own.`);
82
+ // `process.exitCode`, not `process.exit()` — the same contract `summary()`
83
+ // uses, and the only one a test can drive without taking the runner down.
84
+ process.exitCode = 1;
85
+ }
86
+
87
+ function status(dest) {
88
+ heading('talea skill');
89
+ if (!existsSync(dest)) {
90
+ warn('Not installed.');
91
+ plain(`\n ${c.dim('talea skill install')} put it in ${c.bold(dest)}`);
92
+ return;
93
+ }
94
+ if (!isOurs(dest)) {
95
+ warn(`Something else owns ${c.bold(dest)}.`);
96
+ plain(`\n It is not a file talea wrote, so talea will not touch it.`);
97
+ return;
98
+ }
99
+ const same = readFileSync(dest, 'utf8') === readFileSync(source, 'utf8');
100
+ ok(`Installed at ${c.bold(dest)}`);
101
+ if (!same) {
102
+ plain(`\n ${c.yellow('Out of date')} — ${c.dim('talea skill install')} rewrites it from this version.`);
103
+ }
104
+ }
105
+
106
+ function install(dest) {
107
+ heading('talea skill install');
108
+
109
+ if (existsSync(dest) && !isOurs(dest)) {
110
+ // Somebody's own skill, under the name we want. Never overwrite it: it is
111
+ // work this tool has no claim on, and there is no undo.
112
+ fail(`There is already a skill called "talea" at ${c.bold(dest)}, and talea did not write it.`);
113
+ plain(`\n Move it somewhere else first, then run this again. Nothing has been changed.`);
114
+ process.exitCode = 1;
115
+ return;
116
+ }
117
+
118
+ mkdirSync(path.dirname(dest), { recursive: true });
119
+ copyFileSync(source, dest);
120
+ ok(`Installed ${c.bold(dest)}`);
121
+ info(`Restart Claude Code to pick it up — skills are read at session start.`);
122
+ plain(`\n Then ${c.dim('"where is eklavya?"')} or ${c.dim('"sync my repos"')} reaches the right command on its own.`);
123
+ }
124
+
125
+ function uninstall(dest) {
126
+ heading('talea skill uninstall');
127
+
128
+ if (!existsSync(dest)) {
129
+ info('Nothing to remove.');
130
+ return;
131
+ }
132
+ if (!isOurs(dest)) {
133
+ fail(`${c.bold(dest)} is not a file talea wrote — leaving it alone.`);
134
+ process.exitCode = 1;
135
+ return;
136
+ }
137
+ rmSync(dest, { force: true });
138
+ // The directory is ours too, and an empty one left behind shows up in
139
+ // `/skills` as a skill with no file.
140
+ rmSync(path.dirname(dest), { recursive: true, force: true });
141
+ ok(`Removed ${c.bold(dest)}`);
142
+ }
@@ -29,7 +29,7 @@ Options
29
29
  `;
30
30
 
31
31
  export async function run(opts) {
32
- const { root, manifest, state } = requireWorkspace();
32
+ const { root, manifest, state } = await requireWorkspace();
33
33
  const pool = opts.all ? manifest.repos : machineRepos(manifest, state);
34
34
  const entries = withPaths(manifest, root, selectRepos(manifest, opts, pool));
35
35
 
@@ -31,6 +31,9 @@ your default set, or a checklist of everything in the catalogue with those
31
31
  defaults already ticked. The answer is remembered in ${c.dim('.talea.json')}, so every run
32
32
  after that is a bare ${c.dim('talea sync')}.
33
33
 
34
+ It runs from anywhere. Inside a workspace it uses that one; outside, it uses
35
+ the workspace ${c.dim('talea init')} made, or asks which when this machine has several.
36
+
34
37
  Options
35
38
  -g, --group <names> comma-separated groups
36
39
  -r, --repo <names> comma-separated repo names
@@ -53,7 +56,7 @@ leaves it alone, and says which.
53
56
  `;
54
57
 
55
58
  export async function run(opts) {
56
- const { root, manifest, state } = requireWorkspace();
59
+ const { root, manifest, state } = await requireWorkspace();
57
60
  requireCatalogue(manifest);
58
61
 
59
62
  const protocol = opts.protocol ?? state.protocol ?? 'ssh';
@@ -30,8 +30,8 @@ Options
30
30
 
31
31
  const hasDoc = (dir) => existsSync(path.join(dir, 'CLAUDE.md'));
32
32
 
33
- export function run(opts) {
34
- const { root, manifest, state } = requireWorkspace();
33
+ export async function run(opts) {
34
+ const { root, manifest, state } = await requireWorkspace();
35
35
  const pool = opts.all ? manifest.repos : machineRepos(manifest, state);
36
36
  const entries = withPaths(manifest, root, selectRepos(manifest, opts, pool));
37
37
 
@@ -17,10 +17,13 @@ composes with ${c.dim('cd')}, ${c.dim('code')}, ${c.dim('open')} and anything el
17
17
 
18
18
  Exits non-zero if the repo is not in the catalogue, so ${c.dim('cd $(talea where typo)')}
19
19
  fails loudly instead of landing you in your home directory.
20
+
21
+ Works from outside a workspace too: with one on this machine it uses that, with
22
+ several it asks — on stderr, so the answer never ends up in the path.
20
23
  `;
21
24
 
22
- export function run(opts, positionals = []) {
23
- const { root, manifest } = requireWorkspace();
25
+ export async function run(opts, positionals = []) {
26
+ const { root, manifest } = await requireWorkspace();
24
27
  const name = positionals[0];
25
28
 
26
29
  if (!name) {
package/src/config.js CHANGED
@@ -33,7 +33,7 @@ export const USER_STATE = path.join(USER_DIR, 'state.json');
33
33
 
34
34
  const readJson = (file) => JSON.parse(readFileSync(file, 'utf8'));
35
35
 
36
- /** Machine-wide state: the update-check stamp, the gist id. Not per workspace. */
36
+ /** Machine-wide state: the update-check stamp, the gist id, the workspace list. Not per workspace. */
37
37
  export function readUserState() {
38
38
  try {
39
39
  return readJson(USER_STATE);
@@ -48,7 +48,8 @@ export function writeUserState(state) {
48
48
  writeFileSync(USER_STATE, JSON.stringify(state, null, 2) + '\n');
49
49
  } catch {
50
50
  // A read-only home directory must not break the actual command. The only
51
- // things kept here are a cache stamp and a gist id.
51
+ // things kept here are a cache stamp, a gist id and the workspace list,
52
+ // and a lost list re-fills itself the next time a command runs inside one.
52
53
  }
53
54
  }
54
55
 
@@ -95,6 +96,19 @@ export function findWorkspace(start = process.cwd()) {
95
96
  }
96
97
  }
97
98
 
99
+ /**
100
+ * Every workspace this machine has set up, as recorded in ~/.talea/state.json.
101
+ *
102
+ * This is what lets `talea sync` run from anywhere: outside a workspace the
103
+ * upward walk finds nothing, and this list is what is left to ask. Kept in the
104
+ * machine-wide state rather than the catalogue because a path on this laptop
105
+ * means nothing on the next one. Entries whose .talea.json has gone are skipped
106
+ * on read, not dropped on write — a deleted workspace stops being offered, but
107
+ * an unplugged drive that comes back has not been forgotten.
108
+ */
109
+ export const knownWorkspaces = () =>
110
+ (readUserState().workspaces ?? []).filter((dir) => existsSync(path.join(dir, STATE_FILE)));
111
+
98
112
  export function loadState(workspaceRoot) {
99
113
  const file = path.join(workspaceRoot, STATE_FILE);
100
114
  return existsSync(file) ? readJson(file) : {};
package/src/theme.js CHANGED
@@ -24,18 +24,23 @@ export const useColor =
24
24
  // fallback and uses it whenever COLORTERM is not set.
25
25
  const trueColor = /^(truecolor|24bit)$/i.test(process.env.COLORTERM ?? '');
26
26
 
27
- // ── Phosphor palette ───────────────────────────────────────────
28
- // [24-bit hex, basic-16 SGR code]. The basic code is what a terminal without
29
- // COLORTERM gets; it is deliberately the closest *readable* match rather than
30
- // the closest numerically `faint` has no bright equivalent, so it falls back
31
- // to grey rather than a green nobody can read on a light background.
27
+ // ── Verdigris palette ──────────────────────────────────────────
28
+ // [24-bit hex, basic-16 SGR code]. The same accent ramp the website is built
29
+ // on verdigris, new growth on aged bronze — so the CLI and talea.run are one
30
+ // product rather than two that share a name. The hexes are the `--vd-*` steps in
31
+ // `web/public/tokens.css`; change one and change the other.
32
+ //
33
+ // The basic code is what a terminal without COLORTERM gets. It is deliberately
34
+ // the closest *readable* match rather than the closest numerically — `faint`
35
+ // has no bright equivalent, so it falls back to grey rather than a teal nobody
36
+ // can read on a light background.
32
37
  const PALETTE = {
33
- green: ['#39ff14', 92], // hot phosphor — something worked
34
- aged: ['#1f8a3b', 32], // settled green — a branch, a path, a detail
35
- faint: ['#0e3b1c', 90], // barely lit — rules and fills
36
- amber: ['#ffb000', 33], // attention, but not a failure
37
- red: ['#ff3b30', 31], // failure
38
- bone: ['#d6dbd6', 37], // plain text that still wants to be lit
38
+ green: ['#79D5C4', 96], // --vd-300, the accent — something worked
39
+ aged: ['#199688', 36], // --vd-500 — a branch, a path, a detail
40
+ faint: ['#07403C', 90], // --vd-800 — rules and fills
41
+ amber: ['#E8920C', 33], // --warning: attention, but not a failure
42
+ red: ['#E5484D', 31], // --error: failure
43
+ bone: ['#EAE7E1', 37], // --ink: plain text that still wants to be lit
39
44
  };
40
45
 
41
46
 
package/src/workspace.js CHANGED
@@ -4,9 +4,25 @@
4
4
  // Every command that acts on repos goes through here, so `clone`, `sync`,
5
5
  // `status` and `exec` filter identically.
6
6
 
7
- import { findWorkspace, loadManifest, loadState, repoDir, repoGroup } from './config.js';
7
+ import { existsSync } from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import readline from 'node:readline/promises';
11
+
12
+ import { samePath } from './adopt.js';
13
+ import {
14
+ STATE_FILE,
15
+ findWorkspace,
16
+ knownWorkspaces,
17
+ loadManifest,
18
+ loadState,
19
+ readUserState,
20
+ repoDir,
21
+ repoGroup,
22
+ writeUserState,
23
+ } from './config.js';
8
24
  import { isRepo } from './git.js';
9
- import { fail } from './log.js';
25
+ import { c, fail } from './log.js';
10
26
 
11
27
  const csv = (v) =>
12
28
  (Array.isArray(v) ? v : [v])
@@ -16,16 +32,87 @@ const csv = (v) =>
16
32
  .filter(Boolean);
17
33
 
18
34
  /**
19
- * Resolve the workspace, manifest and state, or exit with a useful message.
20
- * Commands that need an initialised workspace call this first.
35
+ * Add a workspace to this machine's list, once.
36
+ *
37
+ * Compared with samePath, not as strings: on macOS and Windows `~/workspace`
38
+ * and `~/Workspace` are one folder, and cd-ing in with the other spelling would
39
+ * otherwise add it again on every run.
40
+ */
41
+ export function rememberWorkspace(root) {
42
+ const state = readUserState();
43
+ const list = state.workspaces ?? [];
44
+ if (list.some((dir) => samePath(dir, root))) return;
45
+ writeUserState({ ...state, workspaces: [...list, root] });
46
+ }
47
+
48
+ /**
49
+ * The workspaces a command run from outside any of them could mean: every one
50
+ * `init` has recorded, plus the default `~/Workspace` — which is how a
51
+ * workspace made before the list existed is still found from anywhere.
21
52
  */
22
- export function requireWorkspace() {
23
- const root = findWorkspace();
24
- if (!root) {
53
+ export function workspaceCandidates(known = knownWorkspaces(), manifest = loadManifest(null)) {
54
+ const fallback = path.join(os.homedir(), manifest.workspace || 'Workspace');
55
+ const all = existsSync(path.join(fallback, STATE_FILE)) ? [...known, fallback] : known;
56
+ return all.filter((dir, i) => all.findIndex((d) => samePath(d, dir)) === i);
57
+ }
58
+
59
+ /**
60
+ * Outside a workspace: one known workspace is used, several are asked about.
61
+ *
62
+ * Everything here goes to stderr — `cd $(talea where)` reads stdout, and a
63
+ * prompt or a note there is a folder the shell tries to enter. With no
64
+ * terminal to ask on, it stops rather than picks: a script that syncs whichever
65
+ * workspace happened to be first is running against a tree nobody named.
66
+ */
67
+ async function pickKnownWorkspace() {
68
+ const found = workspaceCandidates();
69
+
70
+ if (!found.length) {
25
71
  fail('Not inside a talea workspace (no .talea.json found).');
26
72
  console.error('\n Run `talea init` to create one, or cd into an existing workspace.');
27
73
  process.exit(1);
28
74
  }
75
+ if (found.length === 1) {
76
+ console.error(c.dim(`Using the workspace at ${found[0]}\n`));
77
+ return found[0];
78
+ }
79
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
80
+ fail(`Not inside a talea workspace, and this machine has ${found.length}.`);
81
+ for (const dir of found) console.error(` ${c.bold(dir)}`);
82
+ console.error('\n cd into the one you mean.');
83
+ process.exit(1);
84
+ }
85
+
86
+ console.error(`\n${c.bold('Which workspace?')}`);
87
+ found.forEach((dir, i) => console.error(` ${c.cyan(String(i + 1))} ${dir}`));
88
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
89
+ const answer = (await rl.question(`\n${c.dim(`[1-${found.length}]`)} `)).trim();
90
+ rl.close();
91
+
92
+ // A wrong answer stops, same as a typo'd repo name: guessing here would run
93
+ // a bulk command against a tree the developer did not choose.
94
+ const chosen = found[Number(answer) - 1];
95
+ if (!/^\d+$/.test(answer) || !chosen) {
96
+ fail(`"${answer}" is not one of 1-${found.length}.`);
97
+ process.exit(1);
98
+ }
99
+ console.error('');
100
+ return chosen;
101
+ }
102
+
103
+ /**
104
+ * Resolve the workspace, manifest and state, or exit with a useful message.
105
+ * Commands that need an initialised workspace call this first.
106
+ *
107
+ * Inside a workspace the upward walk wins, exactly as git's does. Outside one,
108
+ * the workspaces this machine knows about are offered instead.
109
+ */
110
+ export async function requireWorkspace() {
111
+ const inside = findWorkspace();
112
+ // Recorded on every run from inside, so a workspace made before the list
113
+ // existed joins it the first time anything is run there.
114
+ if (inside) rememberWorkspace(inside);
115
+ const root = inside ?? (await pickKnownWorkspace());
29
116
  const manifest = loadManifest(root);
30
117
  const state = loadState(root);
31
118
  return { root, manifest, state };