@ajaykumarnpm/talea 0.4.0 → 0.6.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,16 @@ 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.6.0] - 2026-09-22
8
+
9
+ ### Breaking
10
+ - `talea pick <repo>` keeps one repo; `talea update` upgrades the CLI
11
+
12
+ ## [0.5.0] - 2026-09-22
13
+
14
+ ### Added
15
+ - run commands from outside a workspace
16
+
7
17
  ## [0.4.0] - 2026-09-22
8
18
 
9
19
  ### Added
package/README.md CHANGED
@@ -46,6 +46,9 @@ talea manifest push # publish the catalogue so the next machine can rea
46
46
  repos, every org you belong to, and anything shared with you directly. It then
47
47
  asks what this machine should keep and fills the tree.
48
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
+
49
52
  ## Every machine after that
50
53
 
51
54
  ```sh
@@ -63,6 +66,7 @@ talea sync # clone the new, fast-forward the rest
63
66
  talea status # branch, clean/dirty, ahead/behind, in one table
64
67
  talea add some-repo # keep one more on this machine, and clone it now
65
68
  talea select # reopen the checklist and change the whole list
69
+ talea pick some-repo # keep that one — an unknown name opens the checklist
66
70
  cd $(talea where eklavya)
67
71
  ```
68
72
 
@@ -164,7 +168,7 @@ Treat the id like a bookmark you would not paste into a public channel.
164
168
  | `talea clone` | clone only — never fetches or merges |
165
169
  | `talea adopt` | move checkouts you already have into place |
166
170
  | `talea status` | branch, clean/dirty, ahead/behind |
167
- | `talea select` | reopen the checklist — what this machine keeps |
171
+ | `talea select` | reopen the checklist — `talea pick <repo>` for one |
168
172
  | `talea add` / `talea rm` | change that one repo at a time |
169
173
  | `talea where <repo>` | print a repo's path, for `cd $( )` |
170
174
  | `talea list` | the catalogue |
@@ -173,7 +177,7 @@ Treat the id like a bookmark you would not paste into a public channel.
173
177
  | `talea manifest push/pull` | move the catalogue between machines |
174
178
  | `talea skill` | install the skill that lets your coding agent drive talea |
175
179
  | `talea doctor` | check this machine can do the work |
176
- | `talea upgrade` | update the CLI itself |
180
+ | `talea upgrade` | update the CLI itself (also `talea update`) |
177
181
 
178
182
  Every one of them takes `-g <group>` and `-r <repo>` to narrow the run, and
179
183
  `--help` for its own examples.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ajaykumarnpm/talea",
3
- "version": "0.4.0",
3
+ "version": "0.6.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": {
@@ -46,6 +46,10 @@ It exits **non-zero** on an unknown name and writes every diagnostic to stderr,
46
46
  so `cd "$(talea where typo)"` fails instead of landing in the home directory.
47
47
  Never `cd` to a path it did not print.
48
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
+
49
53
  Two owners can own a repo of the same name. When that happens it says so and
50
54
  exits non-zero — pass `owner/name` rather than picking one.
51
55
 
@@ -113,11 +117,17 @@ and nothing in talea will do it for them.
113
117
  talea add some-repo # keep one more here, and clone it now
114
118
  talea rm some-repo # stop keeping it — the checkout stays exactly where it is
115
119
  talea select # reopen the whole checklist, interactively
120
+ talea pick some-repo # = add when the name is exact; otherwise the checklist
116
121
  ```
117
122
 
118
123
  `talea select` needs a terminal. In a non-interactive session it prints a
119
124
  summary instead of hanging, so prefer `add` and `rm` when acting on the
120
125
  developer's behalf and leave `select` as something to suggest they run.
126
+ `pick <name>` falls back to that same checklist on a typo, so an agent uses
127
+ `add`, which fails loudly instead.
128
+
129
+ `talea update` reinstalls the CLI (it is `upgrade`), not `sync`. Never run it
130
+ to refresh repos.
121
131
 
122
132
  `rm` does **not** delete the checkout. Say that plainly when you run it, or it
123
133
  reads like data loss.
package/src/cli.js CHANGED
@@ -52,7 +52,6 @@ const ALIASES = {
52
52
  setup: 'init',
53
53
  bootstrap: 'init',
54
54
  pull: 'sync',
55
- update: 'sync',
56
55
  refresh: 'discover',
57
56
  pick: 'select',
58
57
  choose: 'select',
@@ -63,6 +62,7 @@ const ALIASES = {
63
62
  run: 'exec',
64
63
  skills: 'skill',
65
64
  remove: 'rm',
65
+ update: 'upgrade',
66
66
  'self-update': 'upgrade',
67
67
  };
68
68
 
@@ -110,7 +110,7 @@ ${c.bold('Commands')}
110
110
  ${c.cyan('clone')} clone only — never fetches or merges
111
111
  ${c.cyan('adopt')} move repos you already have into the right place
112
112
  ${c.cyan('status')} one table: branch, clean/dirty, ahead/behind
113
- ${c.cyan('select')} reopen the checklist — what this machine keeps
113
+ ${c.cyan('select')} reopen the checklist — or ${c.dim('talea pick <repo>')} for one
114
114
  ${c.cyan('add')} keep another repo on this machine (${c.dim('rm')} to drop one)
115
115
  ${c.cyan('where')} print a repo's path — ${c.dim('cd $(talea where eklavya)')}
116
116
  ${c.cyan('list')} show the catalogue
@@ -34,12 +34,17 @@ Options
34
34
  -j, --jobs <n> parallel clones
35
35
  `;
36
36
 
37
- /** Resolve `name` or `owner/name` against the catalogue, or exit saying why. */
38
- export function resolve(manifest, name) {
37
+ /** Every catalogue entry `name` or `owner/name` could mean, ignoring case. */
38
+ export function lookup(manifest, name) {
39
39
  const wanted = String(name).toLowerCase();
40
- const matches = manifest.repos.filter(
40
+ return manifest.repos.filter(
41
41
  (r) => r.name.toLowerCase() === wanted || `${r.owner}/${r.name}`.toLowerCase() === wanted,
42
42
  );
43
+ }
44
+
45
+ /** Resolve `name` or `owner/name` against the catalogue, or exit saying why. */
46
+ export function resolve(manifest, name) {
47
+ const matches = lookup(manifest, name);
43
48
 
44
49
  if (!matches.length) {
45
50
  fail(`No repo called "${name}" in the catalogue.`);
@@ -54,7 +59,7 @@ export function resolve(manifest, name) {
54
59
  }
55
60
 
56
61
  export async function run(opts, positionals = []) {
57
- const { root, manifest, state } = requireWorkspace();
62
+ const { root, manifest, state } = await requireWorkspace();
58
63
  requireCatalogue(manifest);
59
64
 
60
65
  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
 
@@ -15,6 +15,7 @@ import { samePath } from '../adopt.js';
15
15
  import { c, context, fail, heading, info, ok, plain, skip, warn } from '../log.js';
16
16
  import { run as discover } from './discover.js';
17
17
  import { run as sync } from './sync.js';
18
+ import { rememberWorkspace } from '../workspace.js';
18
19
 
19
20
  export const help = `
20
21
  ${c.bold('talea init')} — set this machine up
@@ -105,6 +106,8 @@ export async function run(opts, positionals = []) {
105
106
  } else {
106
107
  skip(`${STATE_FILE} already here, leaving it alone`);
107
108
  }
109
+ // So `talea sync` run from outside any workspace knows this one exists.
110
+ rememberWorkspace(target);
108
111
 
109
112
  let manifest = loadManifest(target);
110
113
 
@@ -9,9 +9,10 @@
9
9
  // because `clone` and `sync` ask the same question, and the cloning is
10
10
  // `clone.run`, re-entered after the new selection is on disk.
11
11
 
12
- import { c, fail, heading, ok, plain, skip } from '../log.js';
12
+ import { c, fail, heading, ok, plain, skip, warn } from '../log.js';
13
13
  import { chooseRepos } from '../select.js';
14
14
  import { machineRepos, requireCatalogue, requireWorkspace } from '../workspace.js';
15
+ import * as add from './add.js';
15
16
  import * as clone from './clone.js';
16
17
 
17
18
  export const help = `
@@ -19,6 +20,8 @@ ${c.bold('talea select')} — change what this machine keeps
19
20
 
20
21
  ${c.dim('talea select')} reopen the checklist, then clone what is newly ticked
21
22
  ${c.dim('talea select --no-clone')} change the list only, fill it in later
23
+ ${c.dim('talea pick PiDom')} keep that one repo and clone it — or, if no repo
24
+ has that name, open the checklist instead
22
25
 
23
26
  The checklist opens with the current selection ticked. Space toggles, Enter
24
27
  saves, Esc cancels and changes nothing. Everything in the catalogue is listed,
@@ -29,7 +32,10 @@ It writes ${c.dim('.talea.json')}, which never leaves this machine, so no other
29
32
  selection changes. Unticking a repo takes it off the list and leaves the
30
33
  checkout exactly where it is; deleting it is your call.
31
34
 
32
- For one repo, ${c.dim('talea add <repo>')} and ${c.dim('talea rm <repo>')} skip the checklist.
35
+ A name that matches exactly one repo (case does not matter, ${c.dim('owner/name')} works)
36
+ is ${c.dim('talea add <repo>')}. A name that matches none, or two owners' repos, opens the
37
+ checklist, because a typo should land you in the list rather than nowhere.
38
+ ${c.dim('talea rm <repo>')} takes one off.
33
39
 
34
40
  Options
35
41
  --no-clone save the selection, clone nothing
@@ -37,6 +43,11 @@ Options
37
43
  -j, --jobs <n> parallel clones
38
44
  `;
39
45
 
46
+ /** The names that do not pin down exactly one repo — empty means every one does. */
47
+ export function unresolved(manifest, names) {
48
+ return names.filter((n) => add.lookup(manifest, n).length !== 1);
49
+ }
50
+
40
51
  /** What changed, as two lists of names. Pure, so it can be tested. */
41
52
  export function changes(before, after) {
42
53
  const had = new Set(before);
@@ -47,8 +58,8 @@ export function changes(before, after) {
47
58
  };
48
59
  }
49
60
 
50
- export async function run(opts) {
51
- const { root, manifest, state } = requireWorkspace();
61
+ export async function run(opts, positionals = []) {
62
+ const { root, manifest, state } = await requireWorkspace();
52
63
  requireCatalogue(manifest);
53
64
 
54
65
  // -g/-r narrow a run; they cannot narrow a decision about the whole machine.
@@ -60,6 +71,12 @@ export async function run(opts) {
60
71
  process.exit(1);
61
72
  }
62
73
 
74
+ if (positionals.length) {
75
+ const missing = unresolved(manifest, positionals);
76
+ if (!missing.length) return add.run({ ...opts, removing: false }, positionals);
77
+ warn(`No single repo called ${missing.map((n) => `"${n}"`).join(', ')} — opening the checklist.`);
78
+ }
79
+
63
80
  const before = machineRepos(manifest, state).map((r) => r.name);
64
81
  const { repos } = await chooseRepos({ manifest, root, state, opts: { ...opts, pick: true } });
65
82
  const after = repos.map((r) => r.name);
@@ -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
 
@@ -6,6 +6,7 @@ export const help = `
6
6
  ${c.bold('talea upgrade')} — update the CLI itself
7
7
 
8
8
  ${c.dim('talea upgrade')} reinstall from npm at the latest version
9
+ ${c.dim('talea update')} the same thing
9
10
  ${c.dim('talea upgrade --check')} only report whether one is available
10
11
  ${c.dim('talea upgrade --on')} turn the daily update notice on
11
12
  ${c.dim('talea upgrade --off')} turn the daily update notice off
@@ -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/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 };