agpm-cli 0.4.14

AGent Package Manager - A Git-based package manager for coding agents
Documentation
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
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
//! Integration tests for versioned prefixes feature
//!
//! Tests end-to-end workflows with monorepo-style prefixed tags.

use crate::common::{ManifestBuilder, TestProject};
use agpm_cli::utils::normalize_path_for_storage;
use tokio::fs;

/// Test installing a dependency with a prefixed version constraint
#[tokio::test]
async fn test_install_with_prefixed_constraint() {
    crate::test_config::init_test_env();
    let project = TestProject::new().await.unwrap();
    let source_repo = project.create_source_repo("prefixed").await.unwrap();

    // Create agent files
    fs::create_dir_all(source_repo.path.join("agents")).await.unwrap();
    fs::write(source_repo.path.join("agents/test-agent.md"), "# Test Agent\n\nTest content")
        .await
        .unwrap();

    source_repo.git.add_all().unwrap();
    source_repo.git.commit("Add agents").unwrap();

    // Create prefixed tags for agents
    source_repo.git.tag("agents-v1.0.0").unwrap();
    source_repo.git.tag("agents-v1.2.0").unwrap();
    source_repo.git.tag("agents-v2.0.0").unwrap();

    // Also add some snippets with different prefix
    fs::create_dir_all(source_repo.path.join("snippets")).await.unwrap();
    fs::write(
        source_repo.path.join("snippets/test-snippet.md"),
        "# Test Snippet\n\nSnippet content",
    )
    .await
    .unwrap();

    source_repo.git.add_all().unwrap();
    source_repo.git.commit("Add snippets").unwrap();
    source_repo.git.tag("snippets-v1.0.0").unwrap();
    source_repo.git.tag("snippets-v2.0.0").unwrap();

    // Create manifest with prefixed version constraints
    let source_url = format!("file://{}", normalize_path_for_storage(&source_repo.path));
    let manifest = ManifestBuilder::new()
        .add_source("prefixed", &source_url)
        .add_agent("test-agent", |d| {
            d.source("prefixed").path("agents/test-agent.md").version("agents-^v1.0.0")
        })
        .add_snippet("test-snippet", |d| {
            d.source("prefixed").path("snippets/test-snippet.md").version("snippets-^v2.0.0")
        })
        .build();

    project.write_manifest(&manifest).await.unwrap();

    // Run install
    let output = project.run_agpm(&["install"]).unwrap();
    output.assert_success();

    // Verify lockfile has correct resolved versions
    let lockfile_content =
        fs::read_to_string(project.project_path().join("agpm.lock")).await.unwrap();

    // Should resolve to highest compatible versions
    assert!(
        lockfile_content.contains("agents-v1.2.0"),
        "Should resolve agents-^v1.0.0 to agents-v1.2.0 (highest 1.x)\nActual lockfile:\n{}",
        lockfile_content
    );
    assert!(
        lockfile_content.contains("snippets-v2.0.0"),
        "Should resolve snippets-^v2.0.0 to snippets-v2.0.0"
    );

    // Verify files were installed
    assert!(project.project_path().join(".claude/agents/agpm/test-agent.md").exists());

    // Snippets default to agpm artifact type and install to .agpm/snippets/
    assert!(
        project.project_path().join(".agpm/snippets/test-snippet.md").exists(),
        "Snippet file should be installed in .agpm/snippets/"
    );
}

/// Test that prefixes provide isolation (different prefixes don't interfere)
#[tokio::test]
async fn test_prefix_isolation() {
    crate::test_config::init_test_env();
    let project = TestProject::new().await.unwrap();
    let source_repo = project.create_source_repo("prefixed").await.unwrap();

    fs::create_dir_all(source_repo.path.join("agents")).await.unwrap();
    fs::write(source_repo.path.join("agents/agent.md"), "# Agent\n\nContent").await.unwrap();

    source_repo.git.add_all().unwrap();
    source_repo.git.commit("Initial commit").unwrap();

    // Create tags with different prefixes AND unprefixed
    source_repo.git.tag("agents-v1.5.0").unwrap(); // agents prefix
    source_repo.git.tag("tools-v2.0.0").unwrap(); // tools prefix
    source_repo.git.tag("v1.0.0").unwrap(); // unprefixed

    // Create manifest requesting agents prefix ^v1.0.0
    let source_url = format!("file://{}", normalize_path_for_storage(&source_repo.path));
    let manifest = ManifestBuilder::new()
        .add_source("prefixed", &source_url)
        .add_agent("agent", |d| {
            d.source("prefixed").path("agents/agent.md").version("agents-^v1.0.0")
        })
        .build();

    project.write_manifest(&manifest).await.unwrap();

    let output = project.run_agpm(&["install"]).unwrap();
    output.assert_success();

    let lockfile_content =
        fs::read_to_string(project.project_path().join("agpm.lock")).await.unwrap();

    // Should resolve to agents-v1.5.0, NOT tools-v2.0.0 or v1.0.0
    assert!(lockfile_content.contains("agents-v1.5.0"));
    assert!(!lockfile_content.contains("tools-v2.0.0"));
    assert!(!lockfile_content.contains("version = \"v1.0.0\""));
}

/// Test outdated command with prefixed versions
#[tokio::test]
async fn test_outdated_with_prefixed_versions() {
    crate::test_config::init_test_env();
    let project = TestProject::new().await.unwrap();
    let source_repo = project.create_source_repo("prefixed").await.unwrap();

    fs::create_dir_all(source_repo.path.join("agents")).await.unwrap();
    fs::write(source_repo.path.join("agents/agent.md"), "# Agent\n\nContent").await.unwrap();

    source_repo.git.add_all().unwrap();
    source_repo.git.commit("Initial commit").unwrap();
    source_repo.git.tag("agents-v1.0.0").unwrap();

    // Create manifest locked to agents-v1.0.0
    let source_url = format!("file://{}", normalize_path_for_storage(&source_repo.path));
    let manifest = ManifestBuilder::new()
        .add_source("prefixed", &source_url)
        .add_agent("agent", |d| {
            d.source("prefixed").path("agents/agent.md").version("agents-^v1.0.0")
        })
        .build();

    project.write_manifest(&manifest).await.unwrap();

    // Install with v1.0.0
    let output = project.run_agpm(&["install"]).unwrap();
    output.assert_success();

    // Now add a newer version
    fs::write(source_repo.path.join("agents/agent.md"), "# Agent\n\nUpdated content")
        .await
        .unwrap();
    source_repo.git.add_all().unwrap();
    source_repo.git.commit("Update agent").unwrap();
    source_repo.git.tag("agents-v1.5.0").unwrap();

    // Check for outdated dependencies
    let output = project.run_agpm(&["outdated"]).unwrap();
    output.assert_success();

    // The outdated command should either:
    // 1. Show that agents can be updated from v1.0.0 to v1.5.0
    // 2. Show "All dependencies are up to date" (if constraint already allows v1.5.0)
    // Both are valid since agents-^v1.0.0 allows v1.5.0
    let has_version_info = output.stdout.contains("agents");
    let is_up_to_date = output.stdout.contains("up to date");

    assert!(
        has_version_info || is_up_to_date,
        "Expected outdated to either show version info or 'up to date' message.\nGot: {}",
        output.stdout
    );
}

/// Test that unprefixed constraints don't match prefixed tags
#[tokio::test]
async fn test_unprefixed_constraint_doesnt_match_prefixed_tags() {
    crate::test_config::init_test_env();
    let project = TestProject::new().await.unwrap();
    let source_repo = project.create_source_repo("prefixed").await.unwrap();

    fs::create_dir_all(source_repo.path.join("agents")).await.unwrap();
    fs::write(source_repo.path.join("agents/agent.md"), "# Agent\n\nContent").await.unwrap();

    source_repo.git.add_all().unwrap();
    source_repo.git.commit("Initial commit").unwrap();

    // Only create prefixed tag
    source_repo.git.tag("agents-v1.0.0").unwrap();

    // Create manifest with unprefixed constraint
    let source_url = format!("file://{}", normalize_path_for_storage(&source_repo.path));
    let manifest = ManifestBuilder::new()
        .add_source("prefixed", &source_url)
        .add_agent("agent", |d| d.source("prefixed").path("agents/agent.md").version("^v1.0.0"))
        .build();

    project.write_manifest(&manifest).await.unwrap();

    // Install should fail - no unprefixed tags match
    let output = project.run_agpm(&["install"]).unwrap();

    // Command should fail (success = false)
    assert!(
        !output.success,
        "Expected install to fail when no unprefixed tags match, but it succeeded"
    );

    // Verify error message mentions no matching tags
    assert!(
        output.stderr.contains("No tag found matching") || output.stderr.contains("No tags found"),
        "Expected error about no matching tags, got: {}",
        output.stderr
    );
}

/// Test multi-prefix manifest with agents, snippets, and unprefixed versions coexisting
#[tokio::test]
async fn test_multi_prefix_manifest() {
    crate::test_config::init_test_env();
    let project = TestProject::new().await.unwrap();
    let source_repo = project.create_source_repo("multi").await.unwrap();

    // Create directory structure for different resource types
    fs::create_dir_all(source_repo.path.join("agents")).await.unwrap();
    fs::create_dir_all(source_repo.path.join("snippets")).await.unwrap();
    fs::create_dir_all(source_repo.path.join("commands")).await.unwrap();

    // Create files
    fs::write(source_repo.path.join("agents/test-agent.md"), "# Test Agent\n\nAgent content")
        .await
        .unwrap();
    fs::write(
        source_repo.path.join("snippets/test-snippet.md"),
        "# Test Snippet\n\nSnippet content",
    )
    .await
    .unwrap();
    fs::write(
        source_repo.path.join("commands/test-command.md"),
        "# Test Command\n\nCommand content",
    )
    .await
    .unwrap();

    source_repo.git.add_all().unwrap();
    source_repo.git.commit("Initial commit").unwrap();

    // Create tags with different prefixes AND unprefixed
    source_repo.git.tag("agents-v1.0.0").unwrap();
    source_repo.git.tag("agents-v1.5.0").unwrap();
    source_repo.git.tag("agents-v2.0.0").unwrap();
    source_repo.git.tag("snippets-v1.0.0").unwrap();
    source_repo.git.tag("snippets-v1.5.0").unwrap(); // Compatible with ^v1.0.0
    source_repo.git.tag("snippets-v2.0.0").unwrap(); // NOT compatible with ^v1.0.0
    source_repo.git.tag("v1.0.0").unwrap(); // Unprefixed
    source_repo.git.tag("v1.5.0").unwrap(); // Unprefixed

    // Create manifest with all three: agents prefix, snippets prefix, and unprefixed
    let source_url = format!("file://{}", normalize_path_for_storage(&source_repo.path));
    let manifest = ManifestBuilder::new()
        .add_source("multi", &source_url)
        .add_agent("test-agent", |d| {
            d.source("multi").path("agents/test-agent.md").version("agents-^v1.0.0")
        })
        .add_snippet("test-snippet", |d| {
            d.source("multi").path("snippets/test-snippet.md").version("snippets-^v1.0.0")
        })
        .add_command("test-command", |d| {
            d.source("multi").path("commands/test-command.md").version("^v1.0.0")
        })
        .build();

    project.write_manifest(&manifest).await.unwrap();

    // Run install
    let output = project.run_agpm(&["install"]).unwrap();
    output.assert_success();

    // Verify lockfile has correct resolved versions for all three
    let lockfile_content =
        fs::read_to_string(project.project_path().join("agpm.lock")).await.unwrap();

    // Each prefix namespace should resolve independently
    assert!(
        lockfile_content.contains("agents-v1.5.0"),
        "Should resolve agents-^v1.0.0 to agents-v1.5.0 (highest compatible in agents namespace)\nLockfile:\n{}",
        lockfile_content
    );
    assert!(
        lockfile_content.contains("snippets-v1.5.0"),
        "Should resolve snippets-^v1.0.0 to snippets-v1.5.0 (highest compatible in snippets namespace)\nLockfile:\n{}",
        lockfile_content
    );
    assert!(
        lockfile_content.contains("version = \"v1.5.0\""),
        "Should resolve unprefixed ^v1.0.0 to v1.5.0 (highest compatible unprefixed)\nLockfile:\n{}",
        lockfile_content
    );

    // Verify no cross-namespace contamination and semver constraints respected
    assert!(
        !lockfile_content.contains("agents-v2.0.0"),
        "Should NOT use agents-v2.0.0 (breaks semver constraint ^v1.0.0)"
    );
    assert!(
        !lockfile_content.contains("snippets-v2.0.0"),
        "Should NOT use snippets-v2.0.0 (breaks semver constraint ^v1.0.0)"
    );

    // Verify files were installed
    assert!(
        project.project_path().join(".claude/agents/agpm/test-agent.md").exists(),
        "Agent file should be installed"
    );

    // Snippets default to agpm artifact type and install to .agpm/snippets/
    assert!(
        project.project_path().join(".agpm/snippets/test-snippet.md").exists(),
        "Snippet file should be installed in .agpm/snippets/"
    );

    assert!(
        project.project_path().join(".claude/commands/agpm/test-command.md").exists(),
        "Command file should be installed"
    );
}

/// Test update command with prefixed versions
#[tokio::test]
async fn test_update_command_with_prefixed_versions() {
    crate::test_config::init_test_env();
    let project = TestProject::new().await.unwrap();
    let source_repo = project.create_source_repo("updatetest").await.unwrap();

    // Create initial files
    fs::create_dir_all(source_repo.path.join("agents")).await.unwrap();
    fs::write(source_repo.path.join("agents/agent.md"), "# Agent\n\nInitial content")
        .await
        .unwrap();

    source_repo.git.add_all().unwrap();
    source_repo.git.commit("Initial commit").unwrap();
    source_repo.git.tag("agents-v1.0.0").unwrap();
    source_repo.git.tag("agents-v1.2.0").unwrap();

    // Create manifest with prefixed constraint
    let source_url = format!("file://{}", normalize_path_for_storage(&source_repo.path));
    let manifest = ManifestBuilder::new()
        .add_source("updatetest", &source_url)
        .add_agent("agent", |d| {
            d.source("updatetest").path("agents/agent.md").version("agents-^v1.0.0")
        })
        .build();

    project.write_manifest(&manifest).await.unwrap();

    // Initial install
    let output = project.run_agpm(&["install"]).unwrap();
    output.assert_success();

    // Verify initial lockfile
    let lockfile_content =
        fs::read_to_string(project.project_path().join("agpm.lock")).await.unwrap();
    assert!(lockfile_content.contains("agents-v1.2.0"), "Initial install should use agents-v1.2.0");

    // Add new prefixed version
    fs::write(source_repo.path.join("agents/agent.md"), "# Agent\n\nUpdated content v1.5.0")
        .await
        .unwrap();
    source_repo.git.add_all().unwrap();
    source_repo.git.commit("Update to v1.5.0").unwrap();
    source_repo.git.tag("agents-v1.5.0").unwrap();

    // Run update command
    let output = project.run_agpm(&["update"]).unwrap();
    output.assert_success();

    // Verify lockfile was updated to new version
    let lockfile_content =
        fs::read_to_string(project.project_path().join("agpm.lock")).await.unwrap();
    assert!(
        lockfile_content.contains("agents-v1.5.0"),
        "Update should upgrade to agents-v1.5.0\nLockfile:\n{}",
        lockfile_content
    );
    assert!(!lockfile_content.contains("agents-v1.2.0"), "Update should remove old agents-v1.2.0");

    // Verify file was updated
    let installed_content =
        fs::read_to_string(project.project_path().join(".claude/agents/agpm/agent.md"))
            .await
            .unwrap();
    assert!(
        installed_content.contains("Updated content v1.5.0"),
        "Installed file should have updated content"
    );

    // Now test updating with constraint that excludes new version
    // Add v2.0.0 (which should NOT be installed due to ^v1.0.0 constraint)
    fs::write(source_repo.path.join("agents/agent.md"), "# Agent\n\nUpdated content v2.0.0")
        .await
        .unwrap();
    source_repo.git.add_all().unwrap();
    source_repo.git.commit("Update to v2.0.0").unwrap();
    source_repo.git.tag("agents-v2.0.0").unwrap();

    // Run update again
    let output = project.run_agpm(&["update"]).unwrap();
    output.assert_success();

    // Verify lockfile still has v1.5.0 (not v2.0.0)
    let lockfile_content =
        fs::read_to_string(project.project_path().join("agpm.lock")).await.unwrap();
    assert!(
        lockfile_content.contains("agents-v1.5.0"),
        "Update should keep agents-v1.5.0 (v2.0.0 breaks ^v1.0.0 constraint)\nLockfile:\n{}",
        lockfile_content
    );
    assert!(
        !lockfile_content.contains("agents-v2.0.0"),
        "Update should NOT upgrade to agents-v2.0.0 (breaks semver constraint)"
    );
}