Repository navigation
Expand file tree
/
Copy pathbase.ts
More file actions
193 lines (171 loc) · 7.37 KB
/
Copy pathbase.ts
File metadata and controls
193 lines (171 loc) · 7.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
import path from 'node:path';
import { toolInstallRoot } from '../types.js';
import type { ResourceType, ResourceItem, ResourceDiff, DeliveryTarget, TeamaiConfig, LocalConfig, PendingPush, State } from '../types.js';
/** The placement records `publishedNameFor` resolves a bare name through. */
export type PlacementRecords = Pick<State, 'placedRules' | 'placedAgents'>;
import { readFileSafe, writeFile, ensureDir, pathExists } from '../utils/fs.js';
import { getUserHome } from '../utils/home.js';
import type { DeliveryLedger } from './delivered-copies.js';
const TOMBSTONE_FILE = '.removed';
/**
* Abstract base class for resource handlers.
* Each resource type (skills, rules, docs, env, agents, hooks, mcp) implements this.
*/
/**
* What `push` knows before it scans. The destination an explicit
* `--role`/`--project` names, which only agents read: their scan has to
* decide which team file a local edit is an edit OF, and that answer changes
* when the user has named a namespace (see `AgentsHandler.scanLocalForPush`).
* And the open-PR records, which only skills read: an open PR is proof of
* where a new skill goes, so it decides the copy's destination before copies
* from several tools are merged (see `SkillsHandler.scanLocalForPush`).
*/
export interface ScanForPushOptions {
/** The namespace `--role <ns>` / `--project <id>` resolved to, if any. */
namespace?: string;
/** Open-PR records whose branch is still on origin. */
pending?: readonly PendingPush[];
/** The real path of the copy `push --skill` names: --skill, not the scan, says why it is left out as another namespace's. */
requestedSkillPath?: string;
}
export abstract class ResourceHandler {
abstract readonly type: ResourceType;
/**
* Scan local sources for items that could be pushed to the team repo.
* Returns items found locally that are not yet in the team repo.
*/
abstract scanLocalForPush(
teamConfig: TeamaiConfig,
localConfig: LocalConfig,
options?: ScanForPushOptions,
): Promise<ResourceItem[]>;
/**
* Scan team repo for items that should be pulled to local.
* Returns items from the team repo.
*/
abstract scanTeamForPull(
teamConfig: TeamaiConfig,
localConfig: LocalConfig,
): Promise<ResourceItem[]>;
/**
* Copy a resource item from local to the team repo directory.
*/
abstract pushItem(
item: ResourceItem,
teamConfig: TeamaiConfig,
localConfig: LocalConfig,
): Promise<void>;
/**
* Pull a resource item from the team repo and inject into local AI tool directories.
* With `ledger`, a skill, rule or agent copy the member changed since teamai
* delivered it is kept, and each write is recorded (#822).
*/
abstract pullItem(
item: ResourceItem,
teamConfig: TeamaiConfig,
localConfig: LocalConfig,
ledger?: DeliveryLedger,
): Promise<void>;
/**
* Remove a resource from the team repo and all local AI tool directories.
* Returns the list of paths that were removed.
*/
abstract removeItem(
name: string,
teamConfig: TeamaiConfig,
localConfig: LocalConfig,
): Promise<string[]>;
/**
* The name this resource is published under, when the user typed a different
* one. `remove` matches what the user types against the team repo, where a
* placed resource lives at `<root>/<ns>/<name>`; the author's local copy is
* still at the resource root, so they know it by its bare name and `remove`
* would answer "not found". Handlers that keep a placement record resolve it
* here, from `records` as the caller reconciled them. Returns null when there
* is nothing to translate.
*/
async publishedNameFor(_name: string, _localConfig: LocalConfig, _records: PlacementRecords): Promise<string | null> {
return null;
}
/**
* Where `item` lands for each tool that can receive it on this machine.
*
* Read-only by contract: resolving a destination must never write, so the
* same answer serves the sync and the checks that verify it. Two resolvers
* for one destination is how "Synced N skills" ends up true while a tool
* receives nothing (#598, #624).
*
* A tool that cannot receive the item is absent from the result: not
* installed, no configured path, or outside the item's own targets.
*
* The default is empty, which also covers a resource with no per-tool file
* destination at all. Docs land in one directory, env in one shell profile,
* and hooks and MCP are entries inside a tool's own config file, so those
* keep checks of their own instead of a sentinel tool.
*
* `received` names the items this member receives in this scope, when the
* caller already resolved them; a handler that needs them otherwise
* resolves them itself (rules, for a tool that writes them flat).
*/
async deliveryTargets(
_teamConfig: TeamaiConfig,
_localConfig: LocalConfig,
_item: ResourceItem,
_received?: readonly string[],
): Promise<DeliveryTarget[]> {
return [];
}
/**
* Check if an AI tool is installed by verifying its root directory exists.
* e.g. for toolPath ".codebuddy/skills", checks if ~/.codebuddy/ exists.
* This prevents creating directories for tools the user hasn't installed.
* @param baseDir - Override base directory (defaults to HOME). Used for project scope.
* @param rootSegment - The root to probe, when the caller can name it better
* than the path's shape can (a member's recorded `toolRoots` entry; see
* `toolGateRoot`). Defaults to `toolInstallRoot(toolPath)`.
*/
static async isToolInstalled(toolPath: string, baseDir?: string, rootSegment?: string): Promise<boolean> {
const base = baseDir ?? getUserHome();
const toolRoot = path.join(base, rootSegment ?? toolInstallRoot(toolPath));
return pathExists(toolRoot);
}
/**
* Read the tombstone file (`<type>/.removed`) from the team repo.
* Returns a Set of resource names that have been explicitly deleted.
*/
async readTombstones(localConfig: LocalConfig): Promise<Set<string>> {
const tombstonePath = path.join(localConfig.repo.localPath, this.type, TOMBSTONE_FILE);
const content = await readFileSafe(tombstonePath);
if (!content) return new Set();
return new Set(
content.split('\n').map((l) => l.trim()).filter((l) => l.length > 0),
);
}
/**
* Append a resource name to the tombstone file, deduplicating and sorting.
*/
async addTombstone(name: string, localConfig: LocalConfig): Promise<void> {
const dir = path.join(localConfig.repo.localPath, this.type);
await ensureDir(dir);
const tombstonePath = path.join(dir, TOMBSTONE_FILE);
const existing = await this.readTombstones(localConfig);
existing.add(name);
const sorted = [...existing].sort();
await writeFile(tombstonePath, sorted.join('\n') + '\n');
}
/**
* Compute diff between local and team repo for this resource type.
*/
async diff(
teamConfig: TeamaiConfig,
localConfig: LocalConfig,
): Promise<ResourceDiff> {
const localItems = await this.scanLocalForPush(teamConfig, localConfig);
const teamItems = await this.scanTeamForPull(teamConfig, localConfig);
const teamNames = new Set(teamItems.map((i) => i.name));
const localNames = new Set(localItems.map((i) => i.name));
const added = localItems.filter((i) => !teamNames.has(i.name));
const removed = teamItems.filter((i) => !localNames.has(i.name));
return { added, modified: [], removed };
}
}