Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,29 @@ trello-cli --delete-checklist-item <checklist-id> <item-id>
## License

This project is licensed under the [MIT License](LICENSE).

## Card position

Pass `--pos top`, `--pos bottom`, or a positive numeric position (for example,
`--pos 123.5`) when creating, moving, or updating a card:

```bash
trello-cli --create-card <list-id> "New task" --pos top
trello-cli --move-card <card-id> <list-id> --pos bottom
trello-cli --update-card <card-id> --pos 123.5
```

Creation sends the position in the same request as the new card. Moving applies
it within the destination list; updating changes the position in the current
list without requiring another field. Omitting `--pos` preserves the existing
behavior. Numeric positions use a decimal point, not a comma. Missing, invalid,
or repeated values return `INVALID_PARAM` and exit code 1 before an API request.

Run the offline regression checks with .NET 10:

```bash
dotnet run --project tests/PositionTests.csproj
```

The checks intercept HTTP requests and use dummy credentials; they do not
create or modify real Trello cards.
12 changes: 6 additions & 6 deletions src/Commands/CardCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public async Task GetCardAsync(string cardId)
OutputFormatter.Print(result);
}

public async Task CreateCardAsync(string listId, string name, string? desc = null, string? due = null, string? labels = null, string? members = null)
public async Task CreateCardAsync(string listId, string name, string? desc = null, string? due = null, string? labels = null, string? members = null, string? pos = null)
{
if (string.IsNullOrEmpty(listId))
{
Expand All @@ -63,19 +63,19 @@ public async Task CreateCardAsync(string listId, string name, string? desc = nul
return;
}

var result = await _api.CreateCardAsync(listId, name, desc, due, labels, members);
var result = await _api.CreateCardAsync(listId, name, desc, due, labels, members, pos);
OutputFormatter.Print(result);
}

public async Task UpdateCardAsync(string cardId, string? name, string? desc, string? due, string? labels, string? members, bool? closed = null)
public async Task UpdateCardAsync(string cardId, string? name, string? desc, string? due, string? labels, string? members, bool? closed = null, string? pos = null)
{
if (string.IsNullOrEmpty(cardId))
{
OutputFormatter.Print(ApiResponse<object>.Fail("Card ID required", "MISSING_PARAM"));
return;
}

var result = await _api.UpdateCardAsync(cardId, name, desc, due, null, labels, members, closed);
var result = await _api.UpdateCardAsync(cardId, name, desc, due, null, labels, members, closed, pos);
OutputFormatter.Print(result);
}

Expand Down Expand Up @@ -103,7 +103,7 @@ public async Task UnarchiveCardAsync(string cardId)
OutputFormatter.Print(result);
}

public async Task MoveCardAsync(string cardId, string listId)
public async Task MoveCardAsync(string cardId, string listId, string? pos = null)
{
if (string.IsNullOrEmpty(cardId))
{
Expand All @@ -117,7 +117,7 @@ public async Task MoveCardAsync(string cardId, string listId)
return;
}

var result = await _api.MoveCardAsync(cardId, listId);
var result = await _api.MoveCardAsync(cardId, listId, pos);
OutputFormatter.Print(result);
}

Expand Down
28 changes: 23 additions & 5 deletions src/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
using TrelloCli.Services;
using TrelloCli.Utils;

const string Version = "1.1.0";
const string Version = "1.2.0";

var config = new ConfigService();

Expand Down Expand Up @@ -82,6 +82,17 @@ async Task ExecuteCommand(string[] args)
{
var command = args[0];

string? position = null;
if (command is "--create-card" or "--update-card" or "--move-card")
{
if (!CardPosition.TryRead(args, out position, out var positionError))
{
OutputFormatter.Print(ApiResponse<object>.Fail(positionError!, "INVALID_PARAM"));
Environment.ExitCode = 1;
return;
}
}

switch (command)
{
// Auth
Expand Down Expand Up @@ -142,7 +153,8 @@ await cardCmd.CreateCardAsync(
GetNamedArg(args, "--desc"),
GetNamedArg(args, "--due"),
GetNamedArg(args, "--labels"),
GetNamedArg(args, "--members")
GetNamedArg(args, "--members"),
position
);
break;

Expand All @@ -161,7 +173,8 @@ await cardCmd.UpdateCardAsync(
GetNamedArg(args, "--due"),
GetNamedArg(args, "--labels"),
GetNamedArg(args, "--members"),
closedValue
closedValue,
position
);
break;

Expand All @@ -174,7 +187,7 @@ await cardCmd.UpdateCardAsync(
break;

case "--move-card":
await cardCmd.MoveCardAsync(GetArg(args, 1), GetArg(args, 2));
await cardCmd.MoveCardAsync(GetArg(args, 1), GetArg(args, 2), position);
break;

case "--delete-card":
Expand Down Expand Up @@ -333,16 +346,19 @@ void ShowHelp()
[--due <date>]
[--labels <ids>] Comma-separated label IDs
[--members <ids>] Comma-separated member IDs
[--pos <top|bottom|number>] Position in the list
--update-card <card-id> Update card
[--name <name>]
[--desc <description>]
[--due <date>]
[--labels <ids>]
[--members <ids>]
[--closed <true|false>] Archive/unarchive card
[--pos <top|bottom|number>] Reorder within the current list
--archive-card <card-id> Archive card (shortcut for --update-card --closed true)
--unarchive-card <card-id> Unarchive card (shortcut for --update-card --closed false)
--move-card <card-id> <list-id> Move card to list
[--pos <top|bottom|number>] Position in the destination list
--delete-card <card-id> Delete card
--get-comments <card-id> Get comments on a card
--add-comment <card-id> <text> Add comment to a card
Expand Down Expand Up @@ -382,6 +398,8 @@ requires browser authentication. Use --attach-url to link attachments.
trello-cli --get-boards
trello-cli --get-board abc123
trello-cli --create-card xyz789 ""My Task"" --desc ""Details""
trello-cli --move-card card123 list456
trello-cli --move-card card123 list456 --pos top
trello-cli --update-card card123 --pos bottom
trello-cli --create-card xyz789 ""New task"" --pos top
");
}
12 changes: 8 additions & 4 deletions src/Services/TrelloApiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ public async Task<ApiResponse<Card>> GetCardAsync(string cardId)
}
}

public async Task<ApiResponse<Card>> CreateCardAsync(string listId, string name, string? desc = null, string? due = null, string? labels = null, string? members = null)
public async Task<ApiResponse<Card>> CreateCardAsync(string listId, string name, string? desc = null, string? due = null, string? labels = null, string? members = null, string? pos = null)
{
try
{
Expand All @@ -236,6 +236,8 @@ public async Task<ApiResponse<Card>> CreateCardAsync(string listId, string name,
formData["idLabels"] = labels;
if (!string.IsNullOrEmpty(members))
formData["idMembers"] = members;
if (pos != null)
formData["pos"] = pos;

var content = new FormUrlEncodedContent(formData);
var response = await _http.PostAsync(url, content);
Expand All @@ -257,7 +259,7 @@ public async Task<ApiResponse<Card>> CreateCardAsync(string listId, string name,
}

public async Task<ApiResponse<Card>> UpdateCardAsync(string cardId, string? name = null, string? desc = null,
string? due = null, string? listId = null, string? labels = null, string? members = null, bool? closed = null)
string? due = null, string? listId = null, string? labels = null, string? members = null, bool? closed = null, string? pos = null)
{
try
{
Expand All @@ -274,6 +276,8 @@ public async Task<ApiResponse<Card>> UpdateCardAsync(string cardId, string? name
formData["idLabels"] = labels;
if (members != null)
formData["idMembers"] = members;
if (pos != null)
formData["pos"] = pos;
if (closed.HasValue)
formData["closed"] = closed.Value.ToString().ToLower();

Expand Down Expand Up @@ -304,9 +308,9 @@ public async Task<ApiResponse<Card>> UpdateCardAsync(string cardId, string? name
}
}

public async Task<ApiResponse<Card>> MoveCardAsync(string cardId, string listId)
public async Task<ApiResponse<Card>> MoveCardAsync(string cardId, string listId, string? pos = null)
{
return await UpdateCardAsync(cardId, listId: listId);
return await UpdateCardAsync(cardId, listId: listId, pos: pos);
}

public async Task<ApiResponse<bool>> DeleteCardAsync(string cardId)
Expand Down
2 changes: 1 addition & 1 deletion src/TrelloCli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

<!-- Package Info -->
<PackageId>TrelloCli</PackageId>
<Version>1.1.0</Version>
<Version>1.2.0</Version>
<Authors>Aygün Özgür</Authors>
<Description>CLI tool for Trello with AI-friendly JSON output</Description>
</PropertyGroup>
Expand Down
33 changes: 33 additions & 0 deletions src/Utils/CardPosition.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Globalization;

namespace TrelloCli.Utils;

public static class CardPosition
{
public static bool TryRead(string[] args, out string? position, out string? error)
{
position = null;
error = null;
var firstOption = args[0] == "--update-card" ? 2 : 3;
for (var i = firstOption; i < args.Length; i++)
{
if (args[i] != "--pos")
{
if (args[i] is "--name" or "--desc" or "--due" or "--labels" or "--members" or "--closed")
i++;
continue;
}
if (position != null || i + 1 >= args.Length || !IsValid(args[i + 1]))
{
error = "--pos requires top, bottom, or a positive finite number (decimal point: .); specify it once";
return false;
}
position = args[++i];
}
return true;
}

private static bool IsValid(string value) => value is "top" or "bottom" ||
(double.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var number)
&& double.IsFinite(number) && number > 0);
}
1 change: 1 addition & 0 deletions tests/PositionTests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup><ItemGroup><ProjectReference Include="../src/TrelloCli.csproj" /></ItemGroup></Project>
74 changes: 74 additions & 0 deletions tests/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System.Net;
using System.Reflection;
using System.Text.Json;
using TrelloCli.Commands;
using TrelloCli.Services;
using TrelloCli.Utils;

Environment.SetEnvironmentVariable("TRELLO_API_KEY", "test-key");
Environment.SetEnvironmentVariable("TRELLO_TOKEN", "test-token");
var handler = new CaptureHandler();
var api = new TrelloApiService(new ConfigService());
typeof(TrelloApiService).GetField("_http", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(api, new HttpClient(handler));
var commands = new CardCommands(api);
var count = 0;
foreach (var pos in new string?[] { "top", "bottom", "123.5", null })
{
foreach (var operation in new[] { "create", "move", "update" })
{
handler.Calls = 0;
var output = new StringWriter();
var original = Console.Out;
Console.SetOut(output);
try
{
if (operation == "create") await commands.CreateCardAsync("list", "Task", pos: pos);
if (operation == "move") await commands.MoveCardAsync("card", "list", pos: pos);
if (operation == "update") await commands.UpdateCardAsync("card", "Task", null, null, null, null, pos: pos);
}
finally { Console.SetOut(original); }
Check(JsonDocument.Parse(output.ToString()).RootElement.GetProperty("ok").GetBoolean(), "command failed");
Check(handler.Calls == 1, "must use exactly one request");
Check(handler.Method == (operation == "create" ? HttpMethod.Post : HttpMethod.Put), "wrong method");
Check(handler.Path == (operation == "create" ? "/1/cards" : "/1/cards/card"), "wrong endpoint");
var fields = handler.Body.Split('&').Select(x => x.Split('=', 2)).ToDictionary(x => x[0], x => Uri.UnescapeDataString(x[1]));
Check(pos == null ? !fields.ContainsKey("pos") : fields.GetValueOrDefault("pos") == pos, "position not sent correctly");
if (operation != "update") Check(fields.GetValueOrDefault("idList") == "list", "list missing");
count++;
}
}
handler.Calls = 0;
await commands.UpdateCardAsync("card", null, null, null, null, null, pos: "top");
Check(handler.Calls == 1, "position-only update must send request");
foreach (var value in new[] { "top", "bottom", "123.5", "0.25", "140737488470016" })
Check(CardPosition.TryRead(["--update-card", "card", "--pos", value], out var position, out _) && position == value, "valid position rejected");
foreach (var value in new[] { "", "middle", "0", "-1", "NaN", "Infinity", "1,5", "--name", "1e999", " " })
Check(!CardPosition.TryRead(["--update-card", "card", "--pos", value], out _, out _), "invalid position accepted");
Check(!CardPosition.TryRead(["--move-card", "card", "list", "--pos"], out _, out _), "missing position accepted");
Check(!CardPosition.TryRead(["--update-card", "card", "--pos", "top", "--pos", "bottom"], out _, out _), "duplicate position accepted");
Check(CardPosition.TryRead(["--create-card", "list", "Task"], out var absent, out _) && absent == null, "optional position required");
foreach (var arguments in new[] {
new[] { "--create-card", "list", "--pos" },
new[] { "--update-card", "card", "--desc", "--pos" },
new[] { "--create-card", "list", "Task", "--desc", "--pos" }
})
Check(CardPosition.TryRead(arguments, out var literal, out _) && literal == null, "literal option text was parsed as position");
Check(CardPosition.TryRead(["--create-card", "list", "--pos", "--desc", "--pos", "--pos", "top"], out var mixed, out _) && mixed == "top", "position after literal text was lost");
Console.WriteLine($"PASS: {count + 1} HTTP request checks, 22 position parsing checks");

static void Check(bool ok, string message) { if (!ok) throw new Exception(message); }
sealed class CaptureHandler : HttpMessageHandler
{
public int Calls;
public string Body = "";
public string Path = "";
public HttpMethod? Method;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Calls++;
Body = await request.Content!.ReadAsStringAsync(cancellationToken);
Path = request.RequestUri!.AbsolutePath;
Method = request.Method;
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"id\":\"card\",\"name\":\"Task\",\"pos\":123.5}") };
}
}